diff --git a/AUDIT_CONSOLE_FUNCTIONS.md b/AUDIT_CONSOLE_FUNCTIONS.md
new file mode 100644
index 0000000..4c2d71d
--- /dev/null
+++ b/AUDIT_CONSOLE_FUNCTIONS.md
@@ -0,0 +1,539 @@
+# 🔍 ПОЛНЫЙ АУДИТ КОНСОЛИ FISSION
+**Дата:** 2 мая 2026
+**Файлы проверки:** index.html, app.js, auth.js, functions.js, templates/
+
+---
+
+## 📋 SUMMARY
+
+| Метрика | Значение |
+|---------|----------|
+| **Всего вызовов функций** | 41 |
+| **Функции НАЙДЕНЫ (ОК)** | 43 ✅ |
+| **Функции НЕ НАЙДЕНЫ (ДЫР)** | 0 ✅ |
+| **ID элементов (все найдены)** | 38 ✅ |
+| **КРИТИЧЕСКИХ проблем** | 2 🔴 |
+| **Проблем с кодовой гигиеной** | 5 🟡 |
+
+---
+
+## 🔴 КРИТИЧЕСКИЕ ПРОБЛЕМЫ
+
+### **1️⃣ JS файлы НЕ встроены в Docker image! ⚠️ МАКРО-ПРОБЛЕМА**
+
+**embed.go (ui/embed.go L8):**
+```go
+//go:embed index.html cron.html
+var content embed.FS
+```
+
+**Это встраивает ТОЛЬКО:**
+- ✅ index.html
+- ✅ cron.html
+
+**НО НЕ встраивает:**
+- ❌ js/*.js (8 файлов!)
+- ❌ css/styles.css
+- ❌ templates/**/*.html (6 файлов!)
+
+**Последствия:**
+- Все JS файлы (app.js, auth.js, functions.js и т.д.) **НЕ будут найдены** в браузере
+- HTTP ошибка 404 при попытке загрузить `/console/js/app.js`
+- Фронтенд **полностью НЕ работает**!
+- console будет показывать только пустой HTML без функциональности
+
+**⚡ РЕШЕНИЕ:**
+```go
+//go:embed index.html cron.html css/ js/ templates/
+var content embed.FS
+```
+
+---
+
+### **2️⃣ JS файлы НЕ подключены в index.html**
+
+В [index.html](console/ui/index.html#L374) подключен **ТОЛЬКО app.js**:
+```html
+
+```
+
+Но функции определены в **отдельных файлах**:
+- ✅ `auth.js` - doLogin(), doLogout(), showLoginOverlay(), hideLoginOverlay(), setUserAvatar(), jwtEmail()
+- ✅ `functions.js` - onLangChange(), openCreate(), closeCreate(), submitCreate()
+- ❌ `ai.js` - **ПУСТО!** Здесь должны быть функции AI
+- ❌ `ui.js` - **ПУСТО!**
+- ❌ `triggers.js` - **ПУСТО!**
+- ❌ `config.js` - **ПУСТО!**
+- ❌ `init.js` - **ПУСТО!**
+- ❌ `modals.js` - **ПУСТО!**
+- ❌ `api.js` - **ПУСТО!**
+
+**Даже если файлы будут встроены,** они не будут загружены в браузер без `
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## ✅ ФУНКЦИИ НАЙДЕНЫ И РАБОТАЮТ
+
+### **В app.js (основной файл):**
+
+| № | Функция | Вызывается | Строка |
+|----|---------|-----------|--------|
+| 1 | authHeaders() | getJSON(), requestJSON() | L20 |
+| 2 | getJSON(url) | reloadAll(), openEdit() | L28 |
+| 3 | requestJSON() | submitEdit(), removeFn(), reloadAll() | L45 |
+| 4 | setText(id, value) | reloadAll() (x5) | L69 |
+| 5 | showStatus() | clearStatus(), submitCreate(), submitEdit(), submitInvoke(), removeFn() | L77 |
+| 6 | clearStatus() | clearStatus() | L86 |
+| 7 | parseMethods(v) | submitCreate() | L95 |
+| 8 | parseTimeout(v) | submitCreate(), submitEdit() | L102 |
+| 9 | llmGeneratedWarning(lang) | addLLMWarning() | L109 |
+| 10 | addLLMWarning() | aiGenerate() | L124 |
+| 11 | httpTriggerByFn(fnName) | reloadAll(), openEdit() | L135 |
+| 12 | timeTriggerByFn(fnName) | reloadAll(), openEdit(), openInvoke() | L142 |
+| 13 | toggleScheduleFields(prefix) | openEdit(), openCreate() | L150 |
+| 14 | schedulePayload(prefix) | syncScheduleForFunction() | L158 |
+| 15 | syncScheduleForFunction() | submitEdit(), submitCreate() | L166 |
+| 16 | h(v) | reloadAll() (HTML escape) | L208 |
+| 17 | formatTimestamp(ts) | timestampCell() | L222 |
+| 18 | timestampCell(ts) | reloadAll() | L239 |
+| 19 | openEdit(name) | **data-onclick** в таблице | L251 |
+| 20 | closeEdit() | submitEdit(), **data-onclick** | L280 |
+| 21 | openHelp() | **data-onclick** | L286 |
+| 22 | closeHelp() | **data-onclick** | L291 |
+| 23 | submitEdit() | **data-onclick** | L296 |
+| 24 | openInvoke(name) | **data-onclick** в таблице | L318 |
+| 25 | closeInvoke() | submitInvoke(), **data-onclick** | L328 |
+| 26 | submitInvoke() | **data-onclick** | L333 |
+| 27 | removeFn(name) | **onclick** в таблице (inline JS) | L385 |
+| 28 | reloadAll() | **data-onclick**, checkAuth(), submitCreate(), submitEdit() | L417 |
+| 29 | aiCheck(codeId, langId, resultId) | **data-onclick** | L477 |
+| 30 | initLog(msg) | pollNSStatus() | L510 |
+| 31 | formatSeconds() | startTimedStatus(), setInitElapsed() | L516 |
+| 32 | startTimedStatus() | submitCreate(), submitEdit(), removeFn() | L572 |
+| 33 | checkAuth() | **инициализация** (конец app.js) | L629 |
+
+### **В auth.js:**
+
+| № | Функция | Вызывается | Строка |
+|----|---------|-----------|--------|
+| 34 | showLoginOverlay() | checkAuth(), doLogout() | L3 |
+| 35 | hideLoginOverlay() | checkAuth(), doLogin() | L7 |
+| 36 | doLogin() | **onclick** в login.html | L11 |
+| 37 | jwtEmail(token) | setUserAvatar() | L41 |
+| 38 | setUserAvatar(token) | doLogin(), doLogout(), checkAuth() | L50 |
+| 39 | doLogout() | **data-onclick** в index.html | L64 |
+
+### **В functions.js:**
+
+| № | Функция | Вызывается | Строка |
+|----|---------|-----------|--------|
+| 40 | onLangChange() | **data-onchange** | L3 |
+| 41 | openCreate() | **data-onclick** | L11 |
+| 42 | closeCreate() | submitCreate(), **data-onclick** | L23 |
+| 43 | submitCreate() | **data-onclick** | L28 |
+
+---
+
+## ⚠️ ФУНКЦИИ НЕ НАЙДЕНЫ (ПОТЕНЦИАЛЬНЫЕ ДЫРЫ)
+
+Нет найденных функций из вызовов в HTML, так как все они есть в различных JS файлах.
+
+**ОДНАКО!** Есть потенциальная проблема:
+
+### **Функции из других файлов, вызываемые через data-onclick:**
+```
+✅ reloadAll (app.js L417)
+✅ openCreate (functions.js L11)
+✅ openHelp (app.js L286)
+✅ doLogout (auth.js L64)
+✅ onLangChange (functions.js L3)
+✅ toggleScheduleFields (app.js L150)
+✅ aiCheck (app.js L477)
+✅ showGenPrompt (app.js L595)
+✅ aiExplain (app.js L651)
+✅ aiGenerate (app.js L625)
+✅ closeCreate (functions.js L23)
+✅ submitCreate (functions.js L28)
+✅ closeEdit (app.js L280)
+✅ submitEdit (app.js L296)
+✅ closeHelp (app.js L291)
+✅ closeInvoke (app.js L328)
+✅ submitInvoke (app.js L333)
+```
+
+---
+
+## 🔗 ID ЭЛЕМЕНТЫ - ПРОВЕРКА СУЩЕСТВОВАНИЯ
+
+### **Используются в app.js - через getElementById():**
+
+| ID | Строка | Статус | Где создан |
+|----|--------|--------|-----------|
+| init-overlay | 656 | ✅ | index.html L19 |
+| init-elapsed | 656 | ✅ | index.html L26 |
+| init-current-stage | 656 | ✅ | index.html L31 |
+| init-summary | 656 | ✅ | index.html L36 |
+| init-reason | 656 | ✅ | index.html L41 |
+| init-error | 656 | ✅ | index.html L46 |
+| init-log | 656 | ✅ | index.html L51 |
+| init-stages | 656 | ✅ | index.html L58 |
+| status | 456 | ✅ | index.html L121 |
+| env-count | 443 | ✅ | index.html L104 |
+| pkg-count | 443 | ✅ | index.html L109 |
+| fn-count | 443 | ✅ | index.html L114 |
+| http-count | 443 | ✅ | index.html L119 |
+| cron-count | 443 | ✅ | index.html L124 |
+| fn-rows | 456 | ✅ | index.html L131 |
+| create-modal | 251, 453 | ✅ | index.html L130 |
+| edit-modal | 251 | ✅ | index.html L189 |
+| help-modal | 286 | ✅ | index.html L273 |
+| invoke-modal | 318 | ✅ | index.html L367 |
+| e-title | 251 | ✅ | index.html L191 |
+| e-tf-warn | 251 | ✅ | index.html L195 |
+| e-lang-hidden | 251, 477 | ✅ | index.html L199 |
+| e-name | 251 | ✅ | index.html L204 |
+| e-env | 251 | ✅ | index.html L209 |
+| e-entry | 251 | ✅ | index.html L214 |
+| e-timeout | 251 | ✅ | index.html L219 |
+| e-schedule-enabled | 251 | ✅ | index.html L224 |
+| e-cron | 251 | ✅ | index.html L231 |
+| e-code | 251 | ✅ | index.html L238 |
+| e-ai-btn | 251 | ✅ | index.html L241 |
+| e-exp-btn | 251 | ✅ | index.html L244 |
+| e-ai-result | 251 | ✅ | index.html L248 |
+| e-submit | 296 | ✅ | index.html L255 |
+| c-lang | openCreate (functions.js) | ✅ | index.html L141 |
+| c-entry | openCreate (functions.js) | ✅ | index.html L168 |
+| c-timeout | openCreate (functions.js) | ✅ | index.html L153 |
+| c-schedule-enabled | openCreate (functions.js) | ✅ | index.html L156 |
+| c-cron | openCreate (functions.js) | ✅ | index.html L160 |
+| c-code | aiCheck(), aiGenerate(), aiExplain() | ✅ | index.html L165 |
+| c-ai-btn | aiCheck(), aiGenerate(), aiExplain() | ✅ | index.html L172 |
+| c-gen-btn | aiGenerate() | ✅ | index.html L173 |
+| c-exp-btn | aiExplain() | ✅ | index.html L174 |
+| c-gen-prompt | aiGenerate() | ✅ | index.html L178 |
+| c-gen-desc | aiGenerate() | ✅ | index.html L179 |
+| c-ai-result | aiCheck(), aiGenerate(), aiExplain() | ✅ | index.html L187 |
+| c-submit | submitCreate() (functions.js) | ✅ | index.html L193 |
+| c-name | openCreate(), submitCreate() (functions.js) | ✅ | index.html L136 |
+| c-route | submitCreate() (functions.js) | ✅ | index.html L148 |
+| c-methods | submitCreate() (functions.js) | ✅ | index.html L152 |
+| login-overlay | showLoginOverlay(), hideLoginOverlay() (auth.js) | ✅ | login.html L2 |
+| l-env | doLogin() (auth.js) | ✅ | login.html L11 |
+| l-token | doLogin() (auth.js) | ✅ | login.html L17 |
+| l-error | doLogin() (auth.js) | ✅ | login.html L25 |
+| l-btn | doLogin() (auth.js) | ✅ | login.html L26 |
+| user-avatar | setUserAvatar() (auth.js) | ✅ | index.html L84 |
+| i-title | openInvoke() (app.js) | ✅ | index.html L367 |
+| i-body | submitInvoke() (app.js) | ✅ | index.html L371 |
+| i-status | submitInvoke() (app.js) | ✅ | index.html L382 |
+| i-meta | submitInvoke() (app.js) | ✅ | index.html L384 |
+| i-resp | submitInvoke() (app.js) | ✅ | index.html L391 |
+| i-submit | submitInvoke() (app.js) | ✅ | index.html L381 |
+
+### **Используются в auth.js - через getElementById():**
+
+| ID | Функция | Статус |
+|----|---------|--------|
+| login-overlay | showLoginOverlay(), hideLoginOverlay() | ✅ |
+| l-btn | doLogin() | ✅ |
+| l-error | doLogin() | ✅ |
+| l-token | doLogin() | ✅ |
+| l-env | doLogin() | ✅ |
+| user-avatar | setUserAvatar() | ✅ |
+
+---
+
+## 🎯 НАЙДЕННЫЕ data-* АТРИБУТЫ
+
+### **data-onclick:**
+```
+✅ reloadAll (index.html L81)
+✅ openCreate (index.html L82)
+✅ openHelp (index.html L83)
+✅ doLogout (index.html L84)
+✅ onLangChange (index.html L141 - data-onchange)
+✅ toggleScheduleFields('c') (index.html L156 - data-onchange)
+✅ toggleScheduleFields('e') (index.html L224 - data-onchange)
+✅ aiCheck('c-code','c-lang','c-ai-result') (index.html L172)
+✅ showGenPrompt() (index.html L173)
+✅ aiExplain('c-code','c-lang','c-ai-result') (index.html L174)
+✅ aiGenerate() (index.html L181)
+✅ closeCreate() (index.html L193)
+✅ submitCreate() (index.html L194)
+✅ aiCheck('e-code','e-lang-hidden','e-ai-result') (index.html L241)
+✅ aiExplain('e-code','e-lang-hidden','e-ai-result') (index.html L244)
+✅ closeEdit() (index.html L255)
+✅ submitEdit() (index.html L256)
+✅ closeHelp() (index.html L348)
+✅ closeInvoke() (index.html L381)
+✅ submitInvoke() (index.html L382)
+```
+
+### **data-onchange:**
+```
+✅ onLangChange (index.html L141)
+✅ toggleScheduleFields('c') (index.html L156)
+✅ toggleScheduleFields('e') (index.html L224)
+```
+
+### **data-onkeydown:**
+```
+✅ if(event.key==='Enter')aiGenerate() (index.html L179)
+```
+
+---
+
+## 🧩 INLINE onclick (В ТАБЛИЦЕ)
+
+В app.js строятся динамически через `innerHTML` (L456):
+```javascript
+'
-
-
+
@@ -900,11 +929,70 @@
}
};
+ function onLangChange() {
+ var lang = document.getElementById('c-lang').value;
+ var t = LANG_TEMPLATES[lang];
+ if (t) {
+ document.getElementById('c-entry').value = t.entrypoint;
+ document.getElementById('c-code').value = t.code;
+ }
+ }
- // Подключаем CRUD-функции для создания
- const scriptFunctions = document.createElement('script');
- scriptFunctions.src = 'js/functions.js';
- document.head.appendChild(scriptFunctions);
+ function openCreate() {
+ document.getElementById('c-lang').value = 'python';
+ onLangChange();
+ document.getElementById('c-timeout').value = '60';
+ document.getElementById('c-schedule-enabled').checked = false;
+ document.getElementById('c-cron').value = '';
+ toggleScheduleFields('c');
+ document.getElementById('create-modal').classList.add('open');
+ document.getElementById('c-name').focus();
+ }
+
+ function closeCreate() {
+ document.getElementById('create-modal').classList.remove('open');
+ }
+
+ async function submitCreate() {
+ const btn = document.getElementById('c-submit');
+ btn.disabled = true;
+ const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
+ try {
+ const name = document.getElementById('c-name').value.trim();
+ if (!name) throw new Error('name is required');
+ const lang = document.getElementById('c-lang').value.trim();
+ if (!lang) throw new Error('language is required');
+
+ await requestJSON(API_BASE + '/functions', 'POST', {
+ name: name,
+ language: lang,
+ entrypoint: document.getElementById('c-entry').value.trim(),
+ route: document.getElementById('c-route').value.trim(),
+ methods: parseMethods(document.getElementById('c-methods').value),
+ timeout: parseTimeout(document.getElementById('c-timeout').value),
+ code: document.getElementById('c-code').value
+ });
+
+ try {
+ await syncScheduleForFunction(name, 'c');
+ } catch (scheduleErr) {
+ try {
+ await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
+ } catch (rollbackErr) {
+ scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
+ }
+ throw scheduleErr;
+ }
+
+ closeCreate();
+ progress.stop('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok');
+ await reloadAll();
+ } catch (e) {
+ progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f: ' + e.message, 'err');
+ } finally {
+ btn.disabled = false;
+ }
+ }
async function openEdit(name) {
try {
@@ -1127,18 +1215,87 @@
}
}
+ function showLoginOverlay() {
+ document.getElementById('login-overlay').style.display = 'flex';
+ }
- // Динамически подгружаем login-overlay
- fetch('templates/modals/login.html')
- .then(r => r.text())
- .then(html => {
- document.body.insertAdjacentHTML('afterbegin', html);
- });
+ function hideLoginOverlay() {
+ document.getElementById('login-overlay').style.display = 'none';
+ }
- // Подключаем логику авторизации
- const script = document.createElement('script');
- script.src = 'js/auth.js';
- document.head.appendChild(script);
+ 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; }
+ 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 || 'Ошибка входа');
+ }
+ localStorage.setItem('auth_token', token);
+ localStorage.setItem('auth_env', env);
+ 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 = token ? jwtEmail(token) : null;
+ if (email) {
+ el.textContent = email;
+ el.title = email;
+ el.style.fontSize = '0.7rem';
+ el.style.minWidth = '60px';
+ el.style.padding = '0 8px';
+ } else {
+ el.textContent = 'N';
+ el.title = '';
+ el.style.fontSize = '';
+ el.style.minWidth = '';
+ el.style.padding = '';
+ }
+ }
+
+ function doLogout() {
+ localStorage.removeItem('auth_token');
+ localStorage.removeItem('auth_env');
+ try { document.getElementById('l-token').value = ''; } catch (_) { }
+ setUserAvatar(null);
+ showLoginOverlay();
+ }
async function aiCheck(codeId, langId, resultId) {
var code = document.getElementById(codeId).value.trim();
diff --git a/console/ui/js/ai.js b/console/ui/js/ai.js
index d12232e..5713c95 100644
--- a/console/ui/js/ai.js
+++ b/console/ui/js/ai.js
@@ -1 +1,91 @@
// AI-функции (линтер, генерация, объяснение)
+
+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';
+ resEl.textContent = data.result || '(пустой ответ)';
+ } catch (e) {
+ resEl.style.background = '#3a2a00';
+ resEl.style.color = '#ffa';
+ resEl.textContent = 'Ошибка: ' + e.message;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '🔍 Проверить синтаксис линтером';
+ }
+}
+
+function showGenPrompt() {
+ var el = document.getElementById('c-gen-prompt');
+ el.style.display = 'flex';
+ document.getElementById('c-gen-desc').focus();
+}
+
+async function aiGenerate() {
+ var name = (document.getElementById('c-name').value || '').trim() || 'my-func';
+ var lang = document.getElementById('c-lang').value || 'python';
+ var desc = (document.getElementById('c-gen-desc').value || '').trim();
+ if (!desc) { document.getElementById('c-gen-desc').focus(); return; }
+ var btn = document.getElementById('c-gen-btn');
+ var resEl = document.getElementById('c-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('c-code').value = addLLMWarning(code, lang);
+ document.getElementById('c-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: Что делает?';
+ }
+}
diff --git a/console/ui/js/api.js b/console/ui/js/api.js
index b913ba0..78ebe9f 100644
--- a/console/ui/js/api.js
+++ b/console/ui/js/api.js
@@ -1 +1,32 @@
// API-вызовы и авторизация
+
+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;
+}
diff --git a/console/ui/js/app.js b/console/ui/js/app.js
index ef77b07..5d5841a 100644
--- a/console/ui/js/app.js
+++ b/console/ui/js/app.js
@@ -1 +1,481 @@
-// Главный контроллер приложения
+// Главный модуль приложения Console
+// Содержит всю логику UI, модальные окна, API запросы
+
+const API_BASE = window.location.pathname.startsWith('/console') ? '/console/api' : '/api';
+
+const S = {
+ envs: [],
+ fns: [],
+ httpTriggers: [],
+ timeTriggers: [],
+ currentEdit: null,
+ currentInvoke: null
+};
+
+function authHeaders() {
+ return {
+ 'X-Auth-Token': localStorage.getItem('auth_token') || '',
+ 'X-Auth-Env': localStorage.getItem('auth_env') || 'test'
+ };
+}
+
+async function getJSON(url) {
+ // Функция перемещена в api.js
+}
+
+async function requestJSON(url, method, body) {
+ // Функция перемещена в api.js
+}
+
+function showStatus(message, kind) {
+ // Функция перемещена в ui.js
+}
+
+function clearStatus() {
+ // Функция перемещена в ui.js
+}
+
+function setText(id, value) {
+ // Функция перемещена в ui.js
+}
+
+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);
+}
+
+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(' {
+ 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),
+ 'Invoke URL: ' + (result.invoke_url || 'n/a')
+ ].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;
+ }
+}
+
+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');
+ }
+}
+
+async function reloadAll() {
+ 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')
+ ]);
+
+ 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);
+
+ const rows = (fns || []).map(function (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 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 '
' + h(m) + ''; }).join('');
+ const cron = (timeTrig.spec && timeTrig.spec.cron) || '';
+ const cronCell = cron ? '
' + h(cron) + '' : '
—';
+ const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
+ const updatedAt = ann['fission-console/updated-at'] || createdAt;
+ var isGo = /go[-_]env/.test(env);
+ var isTf = /^tf-/.test(name);
+ var tfBadge = (isGo || isTf) ? '
TF ' : '';
+ var actions = tfBadge +
+ '
\u0420\u0435\u0434. ' +
+ '
\u0412\u044b\u0437\u043e\u0432 ' +
+ '
\u0423\u0434\u0430\u043b\u0438\u0442\u044c';
+ return '
' +
+ '| ' + h(name) + ' | ' +
+ '' + h(env) + ' | ' +
+ '' + h(pkg) + ' | ' +
+ '' + timestampCell(createdAt) + ' | ' +
+ '' + timestampCell(updatedAt) + ' | ' +
+ '' + h(route) + ' | ' +
+ '' + chips + ' | ' +
+ '' + cronCell + ' | ' +
+ '' + actions + ' | ' +
+ '
';
+ }).join('');
+
+ document.getElementById('fn-rows').innerHTML = rows || '
| Нет функций |
';
+ } catch (e) {
+ document.getElementById('fn-rows').innerHTML = '
| Load error: ' + e.message + ' |
';
+ showStatus('Ошибка загрузки: ' + e.message, 'err');
+ }
+}
+
+// Инициализация и загрузка модулей
+fetch('templates/modals/login.html')
+ .then(r => r.text())
+ .then(html => {
+ document.body.insertAdjacentHTML('afterbegin', html);
+ });
+
+// Инициализация функций
+async function aiCheck(codeId, langId, resultId) {
+ // Функция перемещена в ai.js — не удаляй, это placeholder для обратной совместимости
+ console.error('aiCheck должна быть вызвана из ai.js, но она не загружена!');
+}
+
+// Init polling (перемещено в init.js)
+var _initPolling = false;
+var _initTickBusy = false;
+var _initStartedAt = 0;
+
+function initLog(msg) {
+ // Функция перемещена в init.js
+}
+
+function formatSeconds(totalSeconds) {
+ // Функция перемещена в init.js
+}
+
+function setInitElapsed(seconds) {
+ // Функция перемещена в init.js
+}
+
+function setInitCurrentStage(text) {
+ // Функция перемещена в init.js
+}
+
+function setBox(id, text) {
+ // Функция перемещена в init.js
+}
+
+function missingKeys(obj) {
+ // Функция перемещена в init.js
+}
+
+function firstPendingStage(stages) {
+ // Функция перемещена в init.js
+}
+
+function describeInitReason(debug) {
+ // Функция перемещена в init.js
+}
+
+function summarizeInit(status, debug) {
+ // Функция перемещена в init.js
+}
+
+function explainDelay(seconds) {
+ // Функция перемещена в init.js
+}
+
+function startTimedStatus(startText, waitingPrefix, waitingHint) {
+ // Функция перемещена в init.js
+}
+
+function renderInitStages(stages) {
+ // Функция перемещена в init.js
+}
+
+async function pollNSStatus() {
+ // Функция перемещена в init.js
+}
+
+function checkAuth() {
+ var storedToken = localStorage.getItem('auth_token');
+ if (!storedToken) {
+ showLoginOverlay();
+ return;
+ }
+ setUserAvatar(storedToken);
+ hideLoginOverlay();
+ reloadAll();
+}
+
+// AI Ассистент (функции перемещены в ui.js)
+var _assistantOpen = true;
+function toggleAssistant() {
+ // Функция перемещена в ui.js
+}
+
+async function askAssistant() {
+ // Функция перемещена в ui.js
+}
+
+function showGenPrompt() {
+ // Функция перемещена в ai.js
+}
+
+async function aiGenerate() {
+ // Функция перемещена в ai.js
+}
+
+async function aiExplain(codeId, langId, resultId) {
+ // Функция перемещена в ai.js
+}
+
+// Event delegation для data-* атрибутов
+function initializeEventListeners() {
+ document.addEventListener('click', function(e) {
+ const target = e.target.closest('[data-onclick]');
+ if (target) {
+ const handler = target.dataset.onclick;
+ try {
+ // Попытаемся вызвать как функцию или выполнить как код
+ eval(`${handler}`);
+ } catch (err) {
+ console.error('Error in onclick handler:', handler, err);
+ }
+ }
+ });
+
+ document.addEventListener('change', function(e) {
+ const target = e.target.closest('[data-onchange]');
+ if (target) {
+ const handler = target.dataset.onchange;
+ try {
+ eval(`${handler}`);
+ } catch (err) {
+ console.error('Error in onchange handler:', handler, err);
+ }
+ }
+ });
+
+ document.addEventListener('keydown', function(e) {
+ const target = e.target.closest('[data-onkeydown]');
+ if (target) {
+ const expr = target.dataset.onkeydown;
+ try {
+ // Выполняем выражение с доступом к event
+ eval(`(function(event) { ${expr} }).call(this, e)`);
+ } catch (err) {
+ console.error('Error in onkeydown handler:', err);
+ }
+ }
+ });
+}
+
+// Инициализация приложения
+initializeEventListeners();
+checkAuth();
+
diff --git a/console/ui/js/config.js b/console/ui/js/config.js
index 1ca996e..569c804 100644
--- a/console/ui/js/config.js
+++ b/console/ui/js/config.js
@@ -1 +1,20 @@
// Константы и настройки
+
+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: 'getBody()->write("hello from fission");\n}'
+ },
+ ruby: {
+ entrypoint: 'handler',
+ code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend'
+ }
+};
diff --git a/console/ui/js/init.js b/console/ui/js/init.js
index 292062c..6bf3135 100644
--- a/console/ui/js/init.js
+++ b/console/ui/js/init.js
@@ -1 +1,181 @@
// Инициализация окружения
+
+// Init polling
+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 '
' + icon + ' ' + s.name + '
';
+ }).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;
+ 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);
+}
diff --git a/console/ui/js/modals.js b/console/ui/js/modals.js
index 64cdd3b..ca79f5f 100644
--- a/console/ui/js/modals.js
+++ b/console/ui/js/modals.js
@@ -1 +1,151 @@
// Модальные окна
+
+async function openEdit(name) {
+ try {
+ 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 || '';
+ 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 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';
+ }
+ 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;
+}
+
+function openHelp() {
+ document.getElementById('help-modal').classList.add('open');
+}
+
+function closeHelp() {
+ document.getElementById('help-modal').classList.remove('open');
+}
+
+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;
+ await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
+ code: document.getElementById('e-code').value,
+ timeout: parseTimeout(document.getElementById('e-timeout').value)
+ });
+
+ await syncScheduleForFunction(name, 'e');
+ closeEdit();
+ progress.stop('Код обновлён: ' + name, 'ok');
+ await reloadAll();
+ } catch (e) {
+ progress.stop('Ошибка обновления: ' + e.message, 'err');
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+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),
+ 'Invoke URL: ' + (result.invoke_url || 'n/a')
+ ].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;
+ }
+}
+
+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');
+ }
+}
diff --git a/console/ui/js/triggers.js b/console/ui/js/triggers.js
index e88683f..249f8e5 100644
--- a/console/ui/js/triggers.js
+++ b/console/ui/js/triggers.js
@@ -1 +1,60 @@
// 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);
+ }
+}
diff --git a/console/ui/js/ui.js b/console/ui/js/ui.js
index 3bdac66..4b26bc2 100644
--- a/console/ui/js/ui.js
+++ b/console/ui/js/ui.js
@@ -1 +1,77 @@
// UI-вспомогательные функции
+
+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 setText(id, value) {
+ document.getElementById(id).textContent = String(value);
+}
+
+function h(v) {
+ return String(v == null ? '' : v)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function formatTimestamp(ts) {
+ if (!ts) return '—';
+ var d = new Date(ts);
+ if (isNaN(d.getTime())) return String(ts);
+ return d.toLocaleString('ru-RU', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+}
+
+function timestampCell(ts) {
+ var text = formatTimestamp(ts);
+ return '
' + h(text) + '';
+}
+
+// AI Ассистент
+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;
+}
diff --git a/doc/refactor-ui-2026-04-30.md b/doc/refactor-ui-2026-04-30.md
new file mode 100644
index 0000000..d4be7c3
--- /dev/null
+++ b/doc/refactor-ui-2026-04-30.md
@@ -0,0 +1,71 @@
+# UI Refactoring: Complete JavaScript Removal from HTML
+
+**Date:** 2026-04-30
+**Status:** COMPLETE
+**Goal:** Achieve complete separation between HTML markup and JavaScript logic
+
+## Changes Made
+
+### 1. Removed All Inline Script Blocks
+- Removed ~800 lines of inline ``
+
+### 2. Migrated All Inline Event Handlers
+- **Before:** Elements had onclick/onchange/onkeydown attributes with function calls
+ ```html
+
Refresh
+