Compare commits

...
Author SHA1 Message Date
“Naeel” e8f06d4ab2 move scripts, update UI, add s3cfg-ngcloud 2026-05-03 08:27:38 +04:00
“Naeel” 3b369f8abf move scripts to scripts/ folder 2026-05-03 08:26:30 +04:00
“Naeel” dbd35e11ff feat(auth): demo-логин ≥6 симв, MultiAuthenticator, UUID v5, v1.3.26 2026-05-02 19:45:10 +04:00
“Naeel” 94a4eea50b fix(ui): email влево, версия вправо у логотипа, v1.3.25 2026-05-02 19:24:28 +04:00
“Naeel” 81367f7a5d feat(auth): auth layer decoupling, версия v1.3.24 в navbar и Help 2026-05-02 19:18:34 +04:00
“Naeel” 7ebdaf743c deploy: v1.3.23 — imagePullPolicy Always, email fix, версия в Help 2026-05-02 18:05:40 +04:00
“Naeel” eb20b9f28d rules: ВСЕГДА увеличивать тег перед docker build 2026-05-02 17:52:36 +04:00
“Naeel” 7be2039491 fix: resolve merge conflict in copilot-instructions.md 2026-05-02 17:47:29 +04:00
“Naeel” 69d71f5e05 restore: .github/ files from commit 9a16ab16 2026-05-02 17:46:28 +04:00
“Naeel” 8a8a2e694d fix(ui): email без синего фона; версия v1.3.21 в подвале Help 2026-05-02 17:41:54 +04:00
“Naeel” ef1256ca73 refactor(ui): step11 - extract app.js (reloadAll + fn table render); fix: reloadAll was accidentally deleted in step10 2026-05-02 17:36:49 +04:00
“Naeel” a034c38a38 refactor(ui): step10 - extract init.js (pollNSStatus, initLog, formatSeconds, explainDelay, startTimedStatus, renderInitStages и др.) 2026-05-02 17:33:43 +04:00
“Naeel” 1f1afa95e1 refactor(ui): step9 - extract ai.js (llmGeneratedWarning, addLLMWarning, aiCheck, aiGenerate, aiExplain, askAssistant, toggleAssistant, showGenPrompt) 2026-05-02 17:30:33 +04:00
“Naeel” 4960bf0ee2 refactor(ui): step8 - extract functions.js (parseMethods, parseTimeout, onLangChange, openCreate/Edit/Invoke CRUD, removeFn) 2026-05-02 17:27:20 +04:00
“Naeel” bef4a545d9 refactor(ui): step7 - extract modals.js (openHelp, closeHelp, openInvoke, closeInvoke, submitInvoke) 2026-05-02 17:24:35 +04:00
“Naeel” ff29177aef refactor(ui): step6 - extract triggers.js (httpTriggerByFn, timeTriggerByFn, toggleScheduleFields, schedulePayload, syncScheduleForFunction) 2026-05-02 17:22:27 +04:00
“Naeel” d61d68f09b refactor(ui): step5 - extract auth.js (doLogin, doLogout, checkAuth, showLoginOverlay, jwtEmail, setUserAvatar) 2026-05-02 17:20:31 +04:00
“Naeel” 38a3c36768 refactor(ui): step4 - extract ui.js (setText, showStatus, clearStatus, h, formatTimestamp, timestampCell) 2026-05-02 17:18:25 +04:00
“Naeel” 3db402925a refactor(ui): step3 - extract api.js (authHeaders, getJSON, requestJSON) 2026-05-02 17:16:32 +04:00
“Naeel” 85ffad3772 refactor(ui): step1+2 - extract CSS to styles.css, LANG_TEMPLATES to config.js 2026-05-02 17:14:01 +04:00
“Naeel” 3e7d08894d feat: working console v1.3.20 from Рабочий-Первомай base 2026-05-02 17:04:06 +04:00
37 changed files with 1971 additions and 1525 deletions
+14 -4
View File
@@ -5,6 +5,14 @@
- Ничего лишнего.
- Код — только по запросу.
## ⛔⛔⛔ ВОПРОС = СТОП
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
1. ТОЛЬКО ответить на вопрос
2. ОСТАНОВИТЬСЯ
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально:
@@ -16,10 +24,12 @@
~/fission/ \
naeel@5.172.178.213:~/terra/fission/
3. Все команды выполнять ТОЛЬКО через SSH:
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
- не выполнять команды локально
- не выполнять инфраструктурные команды локально
- не открывать интерактивные сессии
- не делать цепочки без необходимости
@@ -31,8 +41,8 @@
- ломать рабочее состояние
6. После каждого исправления:
- синхронизация (rsync)
- затем commit через SSH
- git add/commit ЛОКАЛЬНО
- затем синхронизация (rsync) на ВМ
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
+27 -16
View File
@@ -1,25 +1,35 @@
# Правила работы агента
## Файловая система
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
1. УВЕЛИЧИТЬ ТЕГ в `console/deploy/console.yaml` (vX.Y.Z → vX.Y.Z+1)
2. rsync на ВМ
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
4. docker build с НОВЫМ тегом
5. docker push с НОВЫМ тегом
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
Перед любым запуском команд сначала убедиться, что локальная копия и содержимое на ВМ синхронизированы; если есть сомнение в рассинхроне, сверяться с файлами именно на ВМ.
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
Монтирование может слетать. Признак: файлы рассинхронизированы.
## Файловая система (актуально)
```bash
# Размонтировать
fusermount -u ~/remote_dev
# Если завис: sudo umount -l /home/naeel/remote_dev
1. Все файлы редактируются локально: `~/fission`
2. После любых изменений — обязательно rsync на ВМ:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \
naeel@5.172.178.213:~/terra/fission/
# Примонтировать
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
-o cache=no -o no_readahead -o reconnect \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
```
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
7. Только rsync для синхронизации
Пример:
1. Редактируешь локально
2. rsync на ВМ
3. Выполняешь команды через SSH на ВМ
## SSH
@@ -53,6 +63,7 @@ git tag vX.Y.Z && git push origin vX.Y.Z
## Поведение агента
- Не трогать рабочий код без явного указания
- Не делать ничего сверх того, о чём явно попросили
- Не делать НИЧЕГО сверх того, о чём явно приказали — ни git-команд, ни rebase, ни дополнительных шагов
- Если для продолжения нужен выбор — СПРОСИТЬ разрешения, не делать самостоятельно
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
+16 -1
View File
@@ -11,6 +11,7 @@ import (
"time"
"fission-console/internal/api"
"fission-console/internal/auth"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
@@ -36,6 +37,19 @@ func main() {
log.Fatalf("create dynamic client: %v", err)
}
testMode := os.Getenv("FISSION_TEST_MODE") == "true"
var jwtAuth auth.Authenticator
if testMode {
jwtAuth = &auth.TestAuthenticator{}
} else {
jwtAuth = auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil)
}
authenticator := &auth.MultiAuthenticator{
JWT: jwtAuth,
Demo: &auth.DemoAuthenticator{},
}
srv := api.NewServer(api.Config{
Dyn: dyn,
Namespace: namespace,
@@ -45,7 +59,8 @@ func main() {
SATokenPath: envDefault("SA_TOKEN_PATH", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
AuthUser: envDefault("FISSION_AUTH_USERNAME", ""),
AuthPass: envDefault("FISSION_AUTH_PASSWORD", ""),
TestMode: os.Getenv("FISSION_TEST_MODE") == "true",
TestMode: testMode,
Authenticator: authenticator,
// --- ai/ask feature ---
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
LLMKey: os.Getenv("FISSION_LLM_KEY"),
+2 -1
View File
@@ -52,7 +52,8 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.19
image: naeel/fission-console:v1.3.27
imagePullPolicy: Always
ports:
- containerPort: 8090
env:
+45 -110
View File
@@ -2,19 +2,20 @@ package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"fission-console/internal/auth"
)
// ctxKeyNS — ключ для хранения namespace пользователя в context.Context.
// Использует приватный тип чтобы избежать коллизий с ключами из других пакетов.
// ctxKeyNS — ключ для K8s namespace пользователя в context.Context.
type ctxKeyNS struct{}
// ctxKeyIdentity — ключ для UserIdentity пользователя в context.Context.
type ctxKeyIdentity struct{}
// authTokenFromRequest извлекает Bearer токен из X-Auth-Token или Authorization заголовка.
func authTokenFromRequest(r *http.Request) string {
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
if token != "" {
@@ -32,61 +33,57 @@ func authTokenFromRequest(r *http.Request) string {
}
// userNS возвращает namespace пользователя из контекста запроса.
// Устанавливается в authMiddleware после успешной аутентификации.
func (s *Server) userNS(r *http.Request) string {
if ns, ok := r.Context().Value(ctxKeyNS{}).(string); ok && ns != "" {
return ns
}
// Fallback: использовать системный namespace (не должно происходить в prod)
return s.ns
}
// authMiddleware оборачивает handler, добавляя аутентификацию и инициализацию namespace.
// normalizeEnv приводит название стенда к допустимому значению.
func normalizeEnv(env string) string {
switch strings.TrimSpace(strings.ToLower(env)) {
case "prod", "dev", "test":
return strings.TrimSpace(strings.ToLower(env))
}
return "test"
}
// authMiddleware оборачивает handler аутентификацией.
//
// В testMode (FISSION_TEST_MODE=true):
// - Deck API не вызывается
// - X-Test-Sub или X-Auth-Token задают sub → разные namespace-ы для тестирования
//
// В production:
// - X-Auth-Token валидируется через Deck API
// - Namespace вычисляется из JWT claim "sub"
// В testMode: заголовок X-Test-Sub позволяет задать sub напрямую (без токена).
// Иначе: токен передаётся в s.authenticator.Authenticate — детали скрыты за интерфейсом.
func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var ns string
env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env")))
if _, ok := deckAPIs[env]; !ok {
env = "test"
}
var identity auth.UserIdentity
env := normalizeEnv(r.Header.Get("X-Auth-Env"))
if s.testMode {
sub := strings.TrimSpace(r.Header.Get("X-Test-Sub"))
if sub != "" {
ns = namespaceFromSub(sub)
} else {
token := authTokenFromRequest(r)
resolvedNS, err := s.resolveNamespaceForToken(token, env, true)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
if sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")); sub != "" {
identity = auth.UserIdentity{Sub: sub}
ctx := s.contextWithIdentity(r.Context(), identity)
if err := s.nsManager.EnsureUserNS(ctx, auth.NamespaceForSub(sub)); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
ns = resolvedNS
}
} else {
token := authTokenFromRequest(r)
resolvedNS, err := s.resolveNamespaceForToken(token, env, false)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
h(w, r.WithContext(ctx))
return
}
ns = resolvedNS
}
ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns)
token := authTokenFromRequest(r)
var err error
identity, err = s.authenticator.Authenticate(r.Context(), token, env)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
// Гарантируем что namespace + RBAC + quota + netpol существуют.
// EnsureUserNS реализует singleflight + кэш + семафор параллелизма.
if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", ensureErr))
ns := auth.NamespaceForSub(identity.Sub)
ctx := s.contextWithIdentity(r.Context(), identity)
if err := s.nsManager.EnsureUserNS(ctx, ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
@@ -94,72 +91,10 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
}
}
func namespaceFromSub(sub string) string {
h32 := sha256.Sum256([]byte(sub))
return "fission-" + hex.EncodeToString(h32[:8])
}
func (s *Server) resolveNamespaceForToken(token, env string, allowTestSub bool) (string, error) {
token = strings.TrimSpace(token)
if token == "" {
if allowTestSub {
return "", fmt.Errorf("test mode: X-Test-Sub required")
}
return "", fmt.Errorf("unauthorized")
}
if err := s.validateDeckToken(token, env); err == nil {
ns, nsErr := namespaceFromJWT(token)
if nsErr != nil {
return s.ns, nil
}
return ns, nil
}
if allowTestSub && strings.Contains(token, "@") {
return namespaceFromSub(token), nil
}
return "", fmt.Errorf("invalid token")
}
// namespaceFromJWT декодирует JWT payload (без верификации подписи),
// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]).
//
// Подпись не проверяется — токен уже валидирован через Deck API (validateDeckToken).
// Здесь нам нужен только deterministic namespace name из sub claim.
func namespaceFromJWT(token string) (string, error) {
parts := strings.SplitN(token, ".", 3)
if len(parts) != 3 {
return "", fmt.Errorf("invalid JWT format")
}
payload := parts[1]
// JWT использует base64url без padding — добавляем если нужно
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
// base64url без стандартного padding — пробуем оба варианта
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", fmt.Errorf("decode JWT payload: %w", err)
}
}
var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
return "", fmt.Errorf("unmarshal JWT claims: %w", err)
}
sub, _ := claims["sub"].(string)
if sub == "" {
return "", fmt.Errorf("JWT missing sub claim")
}
h := sha256.Sum256([]byte(sub))
return "fission-" + hex.EncodeToString(h[:8]), nil
// contextWithIdentity кладёт UserIdentity и вычисленный namespace в контекст.
func (s *Server) contextWithIdentity(ctx context.Context, identity auth.UserIdentity) context.Context {
ns := auth.NamespaceForSub(identity.Sub)
ctx = context.WithValue(ctx, ctxKeyNS{}, ns)
ctx = context.WithValue(ctx, ctxKeyIdentity{}, identity)
return ctx
}
-112
View File
@@ -1,112 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature"
func TestResolveNamespaceForTokenRejectsInvalidTokenOutsideTestMode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/index.cfm/instances" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
oldDeckAPIs := deckAPIs
deckAPIs = map[string]string{"test": server.URL}
defer func() { deckAPIs = oldDeckAPIs }()
s := &Server{http: server.Client()}
if _, err := s.resolveNamespaceForToken("user@example.com", "test", false); err == nil {
t.Fatal("expected invalid token to be rejected outside test mode")
}
}
func TestResolveNamespaceForTokenAllowsValidatedJWTInTestMode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got == "" {
t.Fatal("expected Authorization header")
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer server.Close()
oldDeckAPIs := deckAPIs
deckAPIs = map[string]string{"test": server.URL}
defer func() { deckAPIs = oldDeckAPIs }()
s := &Server{http: server.Client()}
ns, err := s.resolveNamespaceForToken(validJWTForTests, "test", true)
if err != nil {
t.Fatalf("expected JWT to pass in test mode: %v", err)
}
expected, err := namespaceFromJWT(validJWTForTests)
if err != nil {
t.Fatalf("namespaceFromJWT: %v", err)
}
if ns != expected {
t.Fatalf("expected namespace %q, got %q", expected, ns)
}
}
func TestResolveNamespaceForTokenAllowsEmailFallbackOnlyInTestMode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
oldDeckAPIs := deckAPIs
deckAPIs = map[string]string{"test": server.URL}
defer func() { deckAPIs = oldDeckAPIs }()
s := &Server{http: server.Client()}
ns, err := s.resolveNamespaceForToken("user@example.com", "test", true)
if err != nil {
t.Fatalf("expected email fallback in test mode: %v", err)
}
if ns != namespaceFromSub("user@example.com") {
t.Fatalf("unexpected namespace: %q", ns)
}
_, err = s.resolveNamespaceForToken("user@example.com", "test", false)
if err == nil {
t.Fatal("expected email fallback to be rejected outside test mode")
}
}
func TestValidateDeckTokenCachesSuccessfulValidation(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer server.Close()
oldDeckAPIs := deckAPIs
deckAPIs = map[string]string{"test": server.URL}
defer func() { deckAPIs = oldDeckAPIs }()
s := &Server{http: server.Client()}
if err := s.validateDeckToken(validJWTForTests, "test"); err != nil {
t.Fatalf("first validate failed: %v", err)
}
if err := s.validateDeckToken(validJWTForTests, "test"); err != nil {
t.Fatalf("second validate failed: %v", err)
}
if requests != 1 {
t.Fatalf("expected 1 upstream request because of cache, got %d", requests)
}
if v, ok := s.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) {
t.Fatal("expected token to be cached")
}
}
+7 -7
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"fission-console/internal/auth"
"fission-console/internal/fission"
"fission-console/internal/model"
"fission-console/internal/runtime"
@@ -1018,7 +1019,7 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na
}
// handleAuth обрабатывает POST /console/api/auth.
// Валидирует токен, создаёт namespace, возвращает namespace пользователя.
// Валидирует токен через authenticator, создаёт namespace, возвращает namespace + email.
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -1034,17 +1035,16 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
return
}
env := strings.TrimSpace(strings.ToLower(body.Env))
if _, ok := deckAPIs[env]; !ok {
env = "test"
}
env := normalizeEnv(body.Env)
ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode)
identity, err := s.authenticator.Authenticate(r.Context(), body.Token, env)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid token")
return
}
ns := auth.NamespaceForSub(identity.Sub)
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil {
@@ -1052,7 +1052,7 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns})
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
}
// parseTTL парсит строку TTL и возвращает время истечения.
+7 -54
View File
@@ -5,13 +5,13 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"fission-console/internal/auth"
"fission-console/internal/cloud"
"fission-console/internal/fission"
"fission-console/ui"
@@ -22,20 +22,12 @@ import (
"k8s.io/client-go/dynamic"
)
// deckAPIs — карта окружений Deck API.
// Ключ используется в X-Auth-Env заголовке для выбора нужного сервера.
var deckAPIs = map[string]string{
"prod": "https://deck-api.ngcloud.ru/api/v1",
"dev": "https://deck-api-dev.ngcloud.ru/api/v1",
"test": "https://deck-api-test.ngcloud.ru/api/v1",
}
// defaultSATokenPath — путь к service account токену внутри pod-а.
// Используется для авторизации запросов от console к Fission router.
const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
// Server — основная структура HTTP сервера.
// Содержит все зависимости: kubernetes client, конфиги, кэши токенов.
// Содержит все зависимости: kubernetes client, конфиги, кэши.
type Server struct {
dyn dynamic.Interface
ns string // системный namespace (fallback, обычно "fission")
@@ -44,7 +36,7 @@ type Server struct {
saTokenPath string
invokeTimeout time.Duration
testMode bool // FISSION_TEST_MODE=true — пропускает deck auth
testMode bool // FISSION_TEST_MODE=true — разрешает X-Test-Sub shortcut
authUser string
authPass string
@@ -59,9 +51,8 @@ type Server struct {
cachedJWT string
tokenExpAt time.Time
// tokenCache кэширует результаты валидации Deck токенов.
// Ключ: "env:token", значение: time.Time — когда кэш истекает.
tokenCache sync.Map
// authenticator — слой аутентификации. Сервер не знает деталей реализации.
authenticator auth.Authenticator
// nsManager управляет жизненным циклом пользовательских namespace-ов.
nsManager *cloud.NSManager
@@ -78,6 +69,7 @@ type Config struct {
AuthUser string
AuthPass string
TestMode bool
Authenticator auth.Authenticator // слой аутентификации
LLMUrl string
LLMKey string
}
@@ -94,6 +86,7 @@ func NewServer(cfg Config) *Server {
authUser: cfg.AuthUser,
authPass: cfg.AuthPass,
testMode: cfg.TestMode,
authenticator: cfg.Authenticator,
llmURL: cfg.LLMUrl,
llmKey: cfg.LLMKey,
nsManager: cloud.NewNSManager(cfg.Dyn),
@@ -274,43 +267,3 @@ func (s *Server) getRouterToken() string {
s.tokenExpAt = time.Now().Add(100 * time.Second)
return s.cachedJWT
}
// validateDeckToken проверяет токен через Deck API с кэшированием результата на 5 минут.
func (s *Server) validateDeckToken(token, env string) error {
cacheKey := env + ":" + token
if v, ok := s.tokenCache.Load(cacheKey); ok {
if time.Now().Before(v.(time.Time)) {
return nil // кэш актуален — токен валиден
}
s.tokenCache.Delete(cacheKey)
}
apiBase, ok := deckAPIs[env]
if !ok {
return fmt.Errorf("unknown deck env: %s", env)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/index.cfm/instances", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("invalid token")
}
// Кэшируем успешный результат
s.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute))
return nil
}
+246
View File
@@ -0,0 +1,246 @@
// Package auth реализует слой аутентификации консоли.
//
// Сервер получает UserIdentity (Sub + Email) и больше не знает
// ни про Deck API, ни про JWT подписи, ни про тест/прод режимы.
package auth
import (
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// UserIdentity — идентификатор пользователя, полученный после аутентификации.
type UserIdentity struct {
Sub string // ID пользователя (claim "sub" из JWT) — обязательно
Email string // email — опционально, для отображения в UI
}
// NamespaceForSub вычисляет детерминированный K8s namespace из Sub:
// "fission-" + hex(SHA256(sub)[:8])
func NamespaceForSub(sub string) string {
h := sha256.Sum256([]byte(sub))
return "fission-" + hex.EncodeToString(h[:8])
}
// Authenticator — интерфейс аутентификации. Единственная точка входа.
// Сервер не знает как именно проверяется токен — только вызывает Authenticate.
type Authenticator interface {
Authenticate(ctx context.Context, token, env string) (UserIdentity, error)
}
// --- DeckAuthenticator ---
// DeckAPIs — карта URL Deck API по стенду.
type DeckAPIs map[string]string
// DefaultDeckAPIs — стандартные URL для всех стендов облака.
var DefaultDeckAPIs = DeckAPIs{
"prod": "https://deck-api.ngcloud.ru/api/v1",
"dev": "https://deck-api-dev.ngcloud.ru/api/v1",
"test": "https://deck-api-test.ngcloud.ru/api/v1",
}
// DeckAuthenticator — production реализация.
// Валидирует токен через Deck API, декодирует sub + email из JWT payload.
type DeckAuthenticator struct {
apis DeckAPIs
http *http.Client
tokenCache sync.Map // key: "env:token", value: time.Time (expiry)
}
// NewDeckAuthenticator создаёт DeckAuthenticator.
// Если httpClient == nil — используется клиент с таймаутом 5s.
func NewDeckAuthenticator(apis DeckAPIs, httpClient *http.Client) *DeckAuthenticator {
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
return &DeckAuthenticator{apis: apis, http: httpClient}
}
// Authenticate проверяет токен через Deck API и возвращает UserIdentity.
func (d *DeckAuthenticator) Authenticate(ctx context.Context, token, env string) (UserIdentity, error) {
token = strings.TrimSpace(token)
if token == "" {
return UserIdentity{}, fmt.Errorf("token required")
}
if err := d.validateToken(ctx, token, env); err != nil {
return UserIdentity{}, err
}
return identityFromJWT(token)
}
func (d *DeckAuthenticator) validateToken(ctx context.Context, token, env string) error {
cacheKey := env + ":" + token
if v, ok := d.tokenCache.Load(cacheKey); ok {
if time.Now().Before(v.(time.Time)) {
return nil
}
d.tokenCache.Delete(cacheKey)
}
apiBase, ok := d.apis[env]
if !ok {
return fmt.Errorf("unknown env: %s", env)
}
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, apiBase+"/index.cfm/instances", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("invalid token")
}
d.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute))
return nil
}
// --- TestAuthenticator ---
// TestAuthenticator — реализация для тест-режима. Не ходит в Deck API.
// Принимает JWT с claim "sub" или строку с "@" как email-sub.
type TestAuthenticator struct{}
// Authenticate в тест-режиме: декодирует JWT если возможно,
// иначе использует email-строку как sub.
func (t *TestAuthenticator) Authenticate(_ context.Context, token, _ string) (UserIdentity, error) {
token = strings.TrimSpace(token)
if token == "" {
return UserIdentity{}, fmt.Errorf("token required")
}
if identity, err := identityFromJWT(token); err == nil {
return identity, nil
}
if strings.Contains(token, "@") {
return UserIdentity{Sub: token, Email: token}, nil
}
return UserIdentity{}, fmt.Errorf("invalid token")
}
// --- helpers ---
// identityFromJWT декодирует JWT payload (без верификации подписи).
// Подпись не проверяется — токен уже валидирован через Deck API.
func identityFromJWT(token string) (UserIdentity, error) {
parts := strings.SplitN(token, ".", 3)
if len(parts) != 3 {
return UserIdentity{}, fmt.Errorf("invalid JWT format")
}
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return UserIdentity{}, fmt.Errorf("decode JWT payload: %w", err)
}
}
var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
return UserIdentity{}, fmt.Errorf("unmarshal claims: %w", err)
}
sub, _ := claims["sub"].(string)
if sub == "" {
return UserIdentity{}, fmt.Errorf("JWT missing sub claim")
}
email, _ := claims["email"].(string)
return UserIdentity{Sub: sub, Email: email}, nil
}
// isJWT возвращает true если строка похожа на JWT (три непустые части через точку).
func isJWT(s string) bool {
parts := strings.SplitN(s, ".", 4)
return len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != ""
}
// uuidV5 генерирует детерминированный UUID v5 (RFC 4122) из строки.
// Namespace: произвольный фиксированный UUID для demo-логинов.
func uuidV5(name string) string {
// Фиксированный namespace UUID для demo-логинов
ns := []byte{0xf4, 0x7a, 0xc1, 0x0b, 0x58, 0xcc, 0x43, 0x72, 0xa5, 0x67, 0x0e, 0x02, 0xb2, 0xc3, 0xd4, 0x79}
h := sha1.New()
_, _ = h.Write(ns)
_, _ = h.Write([]byte(name))
sum := h.Sum(nil)
sum[6] = (sum[6] & 0x0f) | 0x50 // version 5
sum[8] = (sum[8] & 0x3f) | 0x80 // RFC 4122 variant
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
}
// demoEmail формирует маскированный email для демо-логина.
// Пример: "mylogin" → "demo-my***in"
func demoEmail(login string) string {
r := []rune(login)
n := len(r)
if n <= 4 {
return "demo-" + string(r)
}
return "demo-" + string(r[:2]) + strings.Repeat("*", n-4) + string(r[n-2:])
}
// --- DemoAuthenticator ---
// DemoAuthenticator — аутентификация по произвольному логину (≥6 символов).
// Не обращается ни к каким внешним сервисам.
// Sub = UUID v5 от логина (детерминированный), Email = маскированный логин.
type DemoAuthenticator struct{}
// Authenticate принимает логин ≥6 символов и возвращает UserIdentity.
func (d *DemoAuthenticator) Authenticate(_ context.Context, login, _ string) (UserIdentity, error) {
login = strings.TrimSpace(login)
if len([]rune(login)) < 6 {
return UserIdentity{}, fmt.Errorf("login must be at least 6 characters")
}
return UserIdentity{Sub: uuidV5(login), Email: demoEmail(login)}, nil
}
// --- MultiAuthenticator ---
// MultiAuthenticator роутит аутентификацию:
// - JWT (три части через точку) → JWT authenticator (DeckAuthenticator или TestAuthenticator)
// - Иначе → DemoAuthenticator
// Оба пути равноправны и возвращают одинаковый UserIdentity.
type MultiAuthenticator struct {
JWT Authenticator
Demo *DemoAuthenticator
}
// Authenticate определяет тип входных данных и делегирует нужному authenticator.
func (m *MultiAuthenticator) Authenticate(ctx context.Context, token, env string) (UserIdentity, error) {
token = strings.TrimSpace(token)
if token == "" {
return UserIdentity{}, fmt.Errorf("token or login required")
}
if isJWT(token) {
return m.JWT.Authenticate(ctx, token, env)
}
return m.Demo.Authenticate(ctx, token, env)
}
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature"
func TestDeckAuthenticatorRejectsInvalidToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client())
if _, err := a.Authenticate(context.Background(), "bad-token", "test"); err == nil {
t.Fatal("expected error for invalid token")
}
}
func TestDeckAuthenticatorAcceptsValidJWT(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
t.Fatal("expected Authorization header")
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client())
identity, err := a.Authenticate(context.Background(), validJWTForTests, "test")
if err != nil {
t.Fatalf("expected success: %v", err)
}
if identity.Sub != "test-user-123" {
t.Fatalf("unexpected sub: %q", identity.Sub)
}
if NamespaceForSub(identity.Sub) != NamespaceForSub("test-user-123") {
t.Fatalf("unexpected namespace: %q", NamespaceForSub(identity.Sub))
}
}
func TestDeckAuthenticatorCachesValidToken(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client())
if _, err := a.Authenticate(context.Background(), validJWTForTests, "test"); err != nil {
t.Fatalf("first auth: %v", err)
}
if _, err := a.Authenticate(context.Background(), validJWTForTests, "test"); err != nil {
t.Fatalf("second auth: %v", err)
}
if requests != 1 {
t.Fatalf("expected 1 upstream request (cache), got %d", requests)
}
// проверяем что кэш выставлен
if v, ok := a.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) {
t.Fatal("expected token to be cached with future expiry")
}
}
func TestTestAuthenticatorAcceptsJWT(t *testing.T) {
a := &TestAuthenticator{}
identity, err := a.Authenticate(context.Background(), validJWTForTests, "test")
if err != nil {
t.Fatalf("expected success: %v", err)
}
if identity.Sub != "test-user-123" {
t.Fatalf("unexpected sub: %q", identity.Sub)
}
}
func TestTestAuthenticatorAcceptsEmail(t *testing.T) {
a := &TestAuthenticator{}
identity, err := a.Authenticate(context.Background(), "user@example.com", "test")
if err != nil {
t.Fatalf("expected success: %v", err)
}
if identity.Sub != "user@example.com" {
t.Fatalf("unexpected sub: %q", identity.Sub)
}
}
func TestTestAuthenticatorRejectsInvalidToken(t *testing.T) {
a := &TestAuthenticator{}
if _, err := a.Authenticate(context.Background(), "notajwt", "test"); err == nil {
t.Fatal("expected error for non-JWT non-email token")
}
}
func TestNamespaceForSub(t *testing.T) {
ns := NamespaceForSub("test-user-123")
if len(ns) == 0 {
t.Fatal("empty namespace")
}
if ns != NamespaceForSub("test-user-123") {
t.Fatal("namespace not deterministic")
}
if NamespaceForSub("a") == NamespaceForSub("b") {
t.Fatal("different subs should produce different namespaces")
}
}
+320
View File
@@ -0,0 +1,320 @@
: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;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
)
//go:embed index.html cron.html
//go:embed index.html cron.html css js
var content embed.FS
func Handler() http.Handler {
+28 -1219
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
/* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */
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';
resEl.textContent = data.result || '(пустой ответ)';
} 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() {
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: Что делает?';
}
}
+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;
}
+70
View File
@@ -0,0 +1,70 @@
/* app.js — основная логика: загрузка данных, таблица функций */
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 '<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;
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> ' : '';
var actions = tfBadge +
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
return '<tr>' +
'<td class="mono">' + h(name) + '</td>' +
'<td>' + h(env) + '</td>' +
'<td class="mono">' + h(pkg) + '</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>';
}).join('');
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="9">Нет функций</td></tr>';
if (!rows) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Нет функций</td></tr>';
}
} catch (e) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Load error: ' + e.message + '</td></tr>';
showStatus('Ошибка загрузки: ' + e.message, 'err');
}
}
+105
View File
@@ -0,0 +1,105 @@
/* 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();
}
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'
}
};
+152
View File
@@ -0,0 +1,152 @@
/* functions.js — CRUD операции с функциями */
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 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;
}
}
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('Функция создана: ' + name, 'ok');
await reloadAll();
} catch (e) {
progress.stop('Ошибка создания: ' + e.message, 'err');
} finally {
btn.disabled = false;
}
}
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;
}
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;
}
}
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');
}
}
+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);
}
+76
View File
@@ -0,0 +1,76 @@
/* modals.js — управление модальными окнами Help и Invoke */
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),
'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;
}
}
+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>';
}
+109
View File
@@ -0,0 +1,109 @@
# Логика авторизации — Fission Console
## Что вводит пользователь
В форме логина (`index.html`, overlay `#login-overlay`):
- **Стенд** (`#l-env`): `dev` / `test` / `prod`
- **Токен** (`#l-token`): JWT-токен из личного кабинета NUBES (Профиль → Токены)
---
## Фронтенд: `doLogin()` → `js/auth.js`
1. Берёт токен и стенд из формы
2. Делает `POST /console/api/auth` с `{ token, env }` в теле
3. Если ответ OK:
- Сохраняет `auth_token` и `auth_env` в **localStorage** браузера
- Вызывает `setUserAvatar(token)` — декодирует email из JWT payload и показывает в navbar
- Скрывает overlay логина
4. Проверяет `GET /console/api/ns/status` — если namespace не готов, показывает overlay инициализации
---
## Бэкенд: `handleAuth` → `handlers.go:1022`
1. Принимает `POST /console/api/auth`
2. Валидирует токен через `resolveNamespaceForToken(token, env, testMode)`
3. Вызывает `EnsureUserNS` — гарантирует что K8s namespace + RBAC + quota существуют
4. Возвращает JSON: `{ ok: true, env: "test", namespace: "fission-..." }`
---
## Валидация токена: `validateDeckToken` → `server.go:279`
**Deck API** — внешний сервис облачной платформы NUBES:
```
dev → https://deck-api-dev.ngcloud.ru/api/v1
test → https://deck-api-test.ngcloud.ru/api/v1
prod → https://deck-api.ngcloud.ru/api/v1
```
Проверка: `GET {deckAPI}/index.cfm/instances` с заголовком `Authorization: Bearer {token}`
- `401` → токен невалиден
- Любой другой ответ → токен принят
- Результат кэшируется на **5 минут** (`sync.Map`)
---
## Вычисление namespace: `namespaceFromJWT` → `auth.go:131`
1. JWT токен **не верифицируется по подписи** — Deck API уже это сделал
2. Декодируется payload (base64url)
3. Извлекается claim `"sub"` (идентификатор пользователя в облаке)
4. Namespace = `"fission-" + hex(SHA256(sub)[:8])`
Пример: sub = `c3fce59430e41b0f...` → namespace `fission-c3fce59430e41b0f`
---
## Email в navbar: `setUserAvatar` / `jwtEmail` → `auth.js`
- `jwtEmail(token)` — декодирует JWT payload на **фронтенде** (без обращения к серверу)
- Берёт `payload.email` или `payload.sub`
- Отображает в `#user-avatar` в левом верхнем углу (вместо синего кружка)
---
## Последующие запросы
Каждый API-запрос от фронтенда включает заголовки:
```
X-Auth-Token: <токен из localStorage>
X-Auth-Env: <стенд из localStorage>
```
`authMiddleware` (`auth.go`) снова валидирует токен (но из кэша — без нового HTTP-запроса к Deck API),
вычисляет namespace и кладёт его в `context.Context` → все хендлеры работают в нужном namespace.
---
## Test-режим (FISSION_TEST_MODE=true)
Вместо реального Deck API — проверяет заголовок `X-Test-Sub`.
Если токен содержит `@` — трактуется как email и используется как sub для вычисления namespace.
Используется в тестах и для live-тестирования без реальных токенов.
---
## Итого — схема
```
Пользователь вводит токен + стенд
│
▼
POST /console/api/auth
│
├─► validateDeckToken → Deck API ({env}.ngcloud.ru) → 200 = ОК
│
├─► namespaceFromJWT → decode JWT payload → claim "sub" → SHA256[:8] → "fission-XXXXXXXX"
│
├─► EnsureUserNS → K8s namespace + RBAC создаются если нет
│
└─► { ok: true, namespace: "fission-..." }
Фронтенд:
├─► localStorage.setItem('auth_token', token)
├─► localStorage.setItem('auth_env', env)
├─► jwtEmail(token) → email из JWT payload → показать в navbar
└─► reloadAll() — загрузить функции
```
+133
View File
@@ -0,0 +1,133 @@
# Рефакторинг UI: монолит → модули
Дата: 2026-05-02
Ветка: второмая
Файл-источник: console/ui/index.html (1651 строк, 73 функции)
---
## Целевая структура
```
console/ui/
├── index.html # только HTML-скелет + <script src="..."> теги
├── cron.html # без изменений
├── css/
│ └── styles.css # все стили (сейчас в <style> блоке index.html)
└── js/
├── config.js # константы (DEFAULT_CODE, язык-шаблоны)
├── api.js # authHeaders, getJSON, requestJSON
├── auth.js # showLoginOverlay, hideLoginOverlay, doLogin, jwtEmail, setUserAvatar, doLogout, checkAuth
├── ui.js # setText, showStatus, clearStatus, h, formatTimestamp, timestampCell
├── functions.js # openCreate, closeCreate, submitCreate, openEdit, closeEdit, submitEdit, removeFn, parseMethods, parseTimeout, onLangChange
├── triggers.js # httpTriggerByFn, timeTriggerByFn, toggleScheduleFields, schedulePayload, syncScheduleForFunction
├── modals.js # openHelp, closeHelp, openInvoke, closeInvoke, submitInvoke
├── ai.js # aiCheck, aiGenerate, aiExplain, askAssistant, toggleAssistant, showGenPrompt, llmGeneratedWarning, addLLMWarning
├── init.js # initLog, pollNSStatus, formatSeconds, setInitElapsed, setInitCurrentStage, setBox, missingKeys, firstPendingStage, describeInitReason, summarizeInit, explainDelay, startTimedStatus, renderInitStages
└── app.js # reloadAll, главный контроллер, window.S = {}
```
embed.go: `//go:embed index.html cron.html css js`
---
## Порядок загрузки в index.html (критично!)
```html
<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/functions.js"></script>
<script src="js/triggers.js"></script>
<script src="js/modals.js"></script>
<script src="js/ai.js"></script>
<script src="js/init.js"></script>
<script src="js/app.js"></script>
```
---
## Шаги (пошагово, с билдом после каждого)
### Шаг 1 — CSS
- Вынести `<style>` блок из index.html в `css/styles.css`
- Заменить на `<link rel="stylesheet" href="css/styles.css">`
- Обновить embed.go
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 2 — config.js
- Вынести: объект `DEFAULT_CODE` (шаблоны кода по языкам)
- Самый изолированный, без зависимостей
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 3 — api.js
- Вынести: `authHeaders`, `getJSON`, `requestJSON`
- Зависит от: ничего (только localStorage)
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 4 — ui.js
- Вынести: `setText`, `showStatus`, `clearStatus`, `h`, `formatTimestamp`, `timestampCell`
- Зависит от: ничего
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 5 — auth.js
- Вынести: `showLoginOverlay`, `hideLoginOverlay`, `doLogin`, `jwtEmail`, `setUserAvatar`, `doLogout`, `checkAuth`
- Зависит от: api.js, ui.js
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 6 — triggers.js
- Вынести: `httpTriggerByFn`, `timeTriggerByFn`, `toggleScheduleFields`, `schedulePayload`, `syncScheduleForFunction`
- Зависит от: api.js, ui.js
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 7 — modals.js
- Вынести: `openHelp`, `closeHelp`, `openInvoke`, `closeInvoke`, `submitInvoke`
- Зависит от: api.js, ui.js
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 8 — functions.js
- Вынести: `openCreate`, `closeCreate`, `submitCreate`, `openEdit`, `closeEdit`, `submitEdit`, `removeFn`, `parseMethods`, `parseTimeout`, `onLangChange`
- Зависит от: api.js, ui.js, triggers.js, config.js
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 9 — ai.js
- Вынести: `aiCheck`, `aiGenerate`, `aiExplain`, `askAssistant`, `toggleAssistant`, `showGenPrompt`, `llmGeneratedWarning`, `addLLMWarning`
- Зависит от: api.js, ui.js
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 10 — init.js
- Вынести: `initLog`, `pollNSStatus`, `formatSeconds`, `setInitElapsed`, `setInitCurrentStage`, `setBox`, `missingKeys`, `firstPendingStage`, `describeInitReason`, `summarizeInit`, `explainDelay`, `startTimedStatus`, `renderInitStages`
- Зависит от: api.js, ui.js, auth.js
- **BUILD + DEPLOY + ПРОВЕРКА**
### Шаг 11 — app.js
- Вынести: `reloadAll`, `window.S = {}`, DOMContentLoaded инициализация
- Зависит от: всё выше
- **BUILD + DEPLOY + ПРОВЕРКА**
---
## Правила
1. После каждого шага index.html должен уменьшиться ровно на то что вынесли
2. Никаких дублирований — функция живёт только в одном файле
3. Глобальные переменные (window.S) остаются в app.js
4. Порядок `<script>` тегов строго по зависимостям
5. Старый код из index.html удалять только после проверки билда
---
## Статус
- [ ] Шаг 1: CSS
- [ ] Шаг 2: config.js
- [ ] Шаг 3: api.js
- [ ] Шаг 4: ui.js
- [ ] Шаг 5: auth.js
- [ ] Шаг 6: triggers.js
- [ ] Шаг 7: modals.js
- [ ] Шаг 8: functions.js
- [ ] Шаг 9: ai.js
- [ ] Шаг 10: init.js
- [ ] Шаг 11: app.js
+7
View File
@@ -0,0 +1,7 @@
[default]
access_key = 0GLQRD38H4I6RBDB0EWJ
secret_key = eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw
host_base = s3.msk-1.ngcloud.ru
host_bucket = %(bucket)s.s3.msk-1.ngcloud.ru
use_https = True
signature_v2 = False
View File
View File