Compare commits

...
Author SHA1 Message Date
Naeel df74a69fde v1.3.27: fix Help modal — add -L flag, demo-login docs 2026-05-03 06:46:27 +03:00
37 changed files with 1179 additions and 2497 deletions
+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.22
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
}
-9
View File
@@ -1,9 +0,0 @@
package api
import (
"net/http"
)
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
-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")
}
}
-19
View File
@@ -1,19 +0,0 @@
package api
import "net/http"
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
+513 -23
View File
@@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -15,6 +16,7 @@ import (
"strings"
"time"
"fission-console/internal/auth"
"fission-console/internal/fission"
"fission-console/internal/model"
"fission-console/internal/runtime"
@@ -36,10 +38,189 @@ const maxCodeSize = 1 << 20
// defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout.
const defaultFunctionInvokeTimeout = 60 * time.Second
func buildDeployArchive(lang, code string) ([]byte, error) {
switch lang {
case "nodejs":
return runtime.BuildJSDeployZip(code)
case "php":
return runtime.BuildScriptZip(code, "main.php")
case "ruby":
return runtime.BuildScriptZip(code, "handler.rb")
default:
return []byte(code), nil
}
}
func (s *Server) resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
if fn != nil {
seconds, found, err := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
if err == nil && found && seconds > 0 {
return time.Duration(seconds) * time.Second
}
}
if s.invokeTimeout > 0 {
return s.invokeTimeout
}
return defaultFunctionInvokeTimeout
}
func normalizeFunctionTimeout(seconds int64) int64 {
if seconds <= 0 {
return int64(defaultFunctionInvokeTimeout / time.Second)
}
return seconds
}
func normalizeRoute(route string) string {
route = strings.TrimSpace(route)
if route == "" || route == "/" {
return "/"
}
if !strings.HasPrefix(route, "/") {
return "/" + route
}
return route
}
func routeAllowsMethod(methods []string, method string) bool {
if len(methods) == 0 {
return true
}
for _, candidate := range methods {
if strings.EqualFold(strings.TrimSpace(candidate), method) {
return true
}
}
return false
}
func appendUniqueMethods(dst []string, src []string) []string {
for _, method := range src {
method = strings.ToUpper(strings.TrimSpace(method))
if method == "" {
continue
}
seen := false
for _, existing := range dst {
if existing == method {
seen = true
break
}
}
if !seen {
dst = append(dst, method)
}
}
return dst
}
func shouldForwardRequestBody(method string) bool {
switch method {
case http.MethodGet, http.MethodHead:
return false
default:
return true
}
}
func copyProxyRequestHeaders(dst, src http.Header) {
for key, values := range src {
switch http.CanonicalHeaderKey(key) {
case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length":
continue
}
for _, value := range values {
dst.Add(key, value)
}
}
}
func copyProxyResponseHeaders(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
if client == nil {
return http.DefaultClient.Do(req)
}
invokeClient := *client
// Для invoke реальный лимит должен определяться context timeout функции,
// а не общим HTTP timeout console.
invokeClient.Timeout = 0
return invokeClient.Do(req)
}
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
// GET → список всех функций, POST → создать новую.
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.FunctionGVR)(w, r)
case http.MethodPost:
s.handleCreateFunction(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleFunctionsAction обрабатывает запросы к /console/api/functions/:name[/action].
// Парсит имя функции и опциональный sub-path ("code", "invoke").
func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
// Убираем оба возможных префикса (legacy /api/ и основной /console/api/)
path := strings.TrimPrefix(r.URL.Path, "/api/functions/")
if path == r.URL.Path {
path = strings.TrimPrefix(r.URL.Path, "/console/api/functions/")
}
path = strings.Trim(path, "/")
if path == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(path, "/")
name := strings.TrimSpace(parts[0])
if name == "" {
writeJSONError(w, http.StatusBadRequest, "function name is required")
return
}
if len(parts) == 1 {
// /functions/:name — CRUD операции с конкретной функцией
switch r.Method {
case http.MethodGet:
s.handleGetFunction(w, r, name)
case http.MethodDelete:
s.handleDeleteFunction(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// /functions/:name/code — обновление кода
if len(parts) == 2 && parts[1] == "code" && r.Method == http.MethodPut {
s.handleUpdateFunctionCode(w, r, name)
return
}
// /functions/:name/invoke — вызов функции
if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost {
s.handleInvokeFunction(w, r, name)
return
}
http.NotFound(w, r)
}
// handleCreateFunction создаёт новую функцию: Package + Function + HTTPTrigger.
//
// Порядок создания: Package → Function → HTTPTrigger.
// При ошибке на любом шаге откатываем уже созданные объекты (best-effort).
// TTL парсится ДО создания объектов — невалидный TTL не оставляет мусор.
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
@@ -121,19 +302,140 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
return
}
result, err := fission.CreateFunction(ctx, s.dyn, ns, req)
if err != nil {
status := http.StatusBadGateway
if apierrors.IsAlreadyExists(err) {
status = http.StatusConflict
} else if apierrors.IsInvalid(err) {
status = http.StatusBadRequest
pkgName := req.Name + "-pkg"
triggerName := req.Name + "-route"
methodValues := make([]any, 0, len(req.Methods))
for _, method := range req.Methods {
methodValues = append(methodValues, method)
}
// Строим Package spec в зависимости от языка:
// - Go: source package → builder job компилирует в .so плагин
// - Node.js: deployment zip с ESM wrapper (package.json + main.js)
// - Остальные: deployment literal с кодом напрямую
var pkgSpec map[string]any
if req.Language == "go" {
srcZip, err := runtime.BuildGoSourceZip(req.Code)
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err))
return
}
writeJSONError(w, status, err.Error())
literal := base64.StdEncoding.EncodeToString(srcZip)
pkgSpec = map[string]any{
"source": map[string]any{
"type": "literal",
"literal": literal,
},
"deployment": map[string]any{},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"buildcommand": "build",
}
} else {
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
return
}
pkgSpec = map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"source": map[string]any{},
}
}
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": pkgSpec,
}}
// Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор
fnAnnotations := map[string]any{
"fission-console/language": req.Language,
}
now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid ttl %q: %v", req.TTL, ttlErr))
return
}
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
}
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
return
}
if apierrors.IsInvalid(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, result)
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
"spec": map[string]any{
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"functionTimeout": req.Timeout,
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
"package": map[string]any{
"packageref": map[string]any{"name": pkgName, "namespace": ns},
"functionName": req.Entrypoint,
},
},
}}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
// Откатываем Package если Function не создалась
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
return
}
httpTrigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"relativeurl": req.Route,
"methods": methodValues,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": req.Name},
},
}}
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
// Откатываем Function и Package
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.Name, metav1.DeleteOptions{})
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": req.Name,
"package": pkgName,
"httptrigger": triggerName,
"route": req.Route,
"expires_at": fnAnnotations["fission-console/expires-at"],
})
}
// handleGetFunction возвращает детали функции: код, environment, route, methods.
@@ -165,7 +467,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if packageName != "" {
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil {
code = extractPackageSourceCode(ctx, s.http, pkg)
code = extractPackageSourceCode(ctx, s, pkg)
}
}
@@ -201,6 +503,10 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
})
}
// handleUpdateFunctionCode обновляет код уже существующей функции.
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
// executor кэширует function service по functionUid и не видит изменений в том же Package.
// Новое имя пакета гарантирует cache miss в executor.
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
var req model.UpdateCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -212,25 +518,109 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
writeJSONError(w, http.StatusBadRequest, "code is required")
return
}
if req.Timeout <= 0 {
req.Timeout = int64(defaultFunctionInvokeTimeout / time.Second)
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
ns := s.userNS(r)
result, err := fission.UpdateFunctionCode(ctx, s.dyn, ns, name, req)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, err.Error())
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, result)
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
// Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
return
}
// Создаём новый Package с уникальным именем.
// Это единственный способ сбросить кэш executor: он кэширует по functionUid и
// не замечает изменений в существующем Package.
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
createdAt := func() time.Time {
ann := fn.GetAnnotations()
if ann != nil {
if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" {
if ts, err := parseRFC3339(v); err == nil {
return ts.UTC()
}
}
}
if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
return ts.UTC()
}
return time.Time{}
}()
now := time.Now().UTC()
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
return
}
// Обновляем Function на новый Package
if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
ensureFunctionTimestamps(fn, now)
if createdAt.IsZero() {
createdAt = now
}
fnAnnotations := fn.GetAnnotations()
if fnAnnotations == nil {
fnAnnotations = map[string]string{}
}
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
// Удаляем старый Package (best effort)
if oldPkgName != "" && oldPkgName != newPkgName {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"package": newPkgName,
})
}
// handleInvokeFunction вызывает функцию через Fission router.
@@ -562,26 +952,108 @@ func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body)
}
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, Package.
// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется.
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
ns := s.userNS(r)
if err := fission.DeleteFunction(ctx, s.dyn, ns, name); err != nil {
status := http.StatusBadGateway
// Получаем Function чтобы знать pkgName и envName для cleanup
var pkgName, envName string
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
return
}
writeJSONError(w, status, err.Error())
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
return
}
pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
envName, _, _ = unstructured.NestedString(fn.Object, "spec", "environment", "name")
// Удаляем связанные HTTPTrigger-ы
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName == name {
_ = s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
}
}
}
// Удаляем связанные TimeTrigger-ы
if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName == name {
_ = s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
}
}
}
if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
if pkgName != "" {
if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err))
return
}
}
// Убираем environment pool pods если язык больше не используется (best-effort)
if envName != "" {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanupCancel()
fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName)
}
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
}
// 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")
return
}
var body struct {
Token string `json:"token"`
Env string `json:"env"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Token) == "" {
writeJSONError(w, http.StatusBadRequest, "token required")
return
}
env := normalizeEnv(body.Env)
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 {
log.Printf("handleAuth: ensureUserNS %s: %v", ns, ensureErr)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
}
// parseTTL парсит строку TTL и возвращает время истечения.
// Поддерживаемые форматы: Go duration (1h, 30m, 24h) и дни (1d, 7d, 30d).
@@ -606,4 +1078,22 @@ func parseTTL(ttl string) (time.Time, error) {
// normalizeMethods приводит список HTTP методов к верхнему регистру, убирает дубли.
// Если список пустой или все элементы пустые — возвращает ["GET"].
func normalizeMethods(in []string) []string {
if len(in) == 0 {
return []string{"GET"}
}
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, method := range in {
m := strings.ToUpper(strings.TrimSpace(method))
if m == "" || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
if len(out) == 0 {
return []string{"GET"}
}
return out
}
-48
View File
@@ -1,48 +0,0 @@
package api
import (
"net/http"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) {
// ...existing code...
}
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
// ...existing code...
}
func resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
// ...existing code...
}
func shouldForwardRequestBody(method string) bool {
// ...existing code...
}
func copyProxyRequestHeaders(dst, src http.Header) {
// ...existing code...
}
func copyProxyResponseHeaders(dst, src http.Header) {
// ...existing code...
}
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
// ...existing code...
}
+4 -7
View File
@@ -24,7 +24,7 @@ import (
//
// Декодирование: если данные — валидный UTF-8, возвращаем как есть.
// Если это zip — ищем известные файлы (main.py, handler.rb и т.д.).
func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg *unstructured.Unstructured) string {
func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.Unstructured) string {
literalPaths := [][]string{
{"spec", "source", "literal"},
{"spec", "deployment", "literal"},
@@ -49,7 +49,7 @@ func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg
if !found || strings.TrimSpace(urlValue) == "" {
continue
}
archiveBytes, err := fetchPackageArchive(ctx, httpClient, urlValue)
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
if err != nil {
continue
}
@@ -62,15 +62,12 @@ func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg
}
// fetchPackageArchive скачивает архив функции по URL из Fission storage.
func fetchPackageArchive(ctx context.Context, httpClient *http.Client, archiveURL string) ([]byte, error) {
func fetchPackageArchive(ctx context.Context, s *Server, archiveURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return nil, err
}
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
resp, err := s.http.Do(req)
if err != nil {
return nil, err
}
+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
}
-25
View File
@@ -1,25 +0,0 @@
package api
import (
"strings"
)
func normalizeFunctionTimeout(seconds int64) int64 {
// ...existing code...
}
func normalizeRoute(route string) string {
// ...existing code...
}
func normalizeMethods(in []string) []string {
// ...existing code...
}
func routeAllowsMethod(methods []string, method string) bool {
// ...existing code...
}
func appendUniqueMethods(dst []string, src []string) []string {
// ...existing code...
}
+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")
}
}
-258
View File
@@ -1,258 +0,0 @@
package fission
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"time"
"fission-console/internal/model"
"fission-console/internal/runtime"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
)
const (
functionCreatedAtAnnotation = "fission-console/createdAt"
functionUpdatedAtAnnotation = "fission-console/updatedAt"
)
// CreateFunctionResult holds the result of a CreateFunction call.
type CreateFunctionResult struct {
Name string `json:"name"`
Package string `json:"package"`
HTTPTrigger string `json:"httptrigger"`
Route string `json:"route"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// UpdateFunctionResult holds the result of an UpdateFunctionCode call.
type UpdateFunctionResult struct {
Updated bool `json:"updated"`
Package string `json:"package"`
}
// CreateFunction orchestrates the creation of a complete Fission function set: Package, Function, and HTTPTrigger.
func CreateFunction(ctx context.Context, dyn dynamic.Interface, ns string, req model.CreateFunctionRequest) (*CreateFunctionResult, error) {
pkgName := req.Name + "-pkg"
triggerName := req.Name + "-route"
methodValues := make([]any, 0, len(req.Methods))
for _, method := range req.Methods {
methodValues = append(methodValues, method)
}
var pkgSpec map[string]any
if req.Language == "go" {
srcZip, err := runtime.BuildGoSourceZip(req.Code)
if err != nil {
return nil, fmt.Errorf("build go source archive: %w", err)
}
literal := base64.StdEncoding.EncodeToString(srcZip)
pkgSpec = map[string]any{
"source": map[string]any{
"type": "literal",
"literal": literal,
},
"deployment": map[string]any{},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"buildcommand": "build",
}
} else {
deployBytes, archiveErr := runtime.BuildDeployArchive(req.Language, req.Code)
if archiveErr != nil {
return nil, fmt.Errorf("build %s archive: %w", req.Language, archiveErr)
}
pkgSpec = map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"source": map[string]any{},
}
}
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": pkgSpec,
}}
fnAnnotations := map[string]any{
"fission-console/language": req.Language,
}
now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
var expiresAtStr string
if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil {
return nil, fmt.Errorf("invalid ttl %q: %w", req.TTL, ttlErr)
}
expiresAtStr = expiresAt.UTC().Format(time.RFC3339)
fnAnnotations["fission-console/expires-at"] = expiresAtStr
}
if _, err := dyn.Resource(PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
return nil, fmt.Errorf("create package: %w", err)
}
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
"spec": map[string]any{
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"functionTimeout": req.Timeout,
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
"package": map[string]any{
"packageref": map[string]any{"name": pkgName, "namespace": ns},
"functionName": req.Entrypoint,
},
},
}}
if _, err := dyn.Resource(FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), pkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("create function: %w", err)
}
httpTrigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"relativeurl": req.Route,
"methods": methodValues,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": req.Name},
},
}}
if _, err := dyn.Resource(HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
_ = dyn.Resource(FunctionGVR).Namespace(ns).Delete(context.Background(), req.Name, metav1.DeleteOptions{})
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), pkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("create httptrigger: %w", err)
}
return &CreateFunctionResult{
Name: req.Name,
Package: pkgName,
HTTPTrigger: triggerName,
Route: req.Route,
ExpiresAt: expiresAtStr,
}, nil
}
// UpdateFunctionCode orchestrates updating the code of an existing function.
func UpdateFunctionCode(ctx context.Context, dyn dynamic.Interface, ns, name string, req model.UpdateCodeRequest) (*UpdateFunctionResult, error) {
fn, err := dyn.Resource(FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get function %q: %w", name, err)
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := runtime.BuildDeployArchive(lang, req.Code)
if archiveErr != nil {
return nil, fmt.Errorf("build %s archive: %w", lang, archiveErr)
}
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
createdPkg, err := dyn.Resource(PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("create new package: %w", err)
}
if err := unstructured.SetNestedField(fn.Object, req.Timeout, "spec", "functionTimeout"); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("set function timeout: %w", err)
}
annotations := fn.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
annotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
fn.SetAnnotations(annotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("set function packageref: %w", err)
}
if _, err := dyn.Resource(FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("update function %q: %w", name, err)
}
if oldPkgName != "" && oldPkgName != newPkgName {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), oldPkgName, metav1.DeleteOptions{})
}
return &UpdateFunctionResult{
Updated: true,
Package: newPkgName,
}, nil
}
// DeleteFunction orchestrates the deletion of a function and its related resources.
func DeleteFunction(ctx context.Context, dyn dynamic.Interface, ns, name string) error {
// Best-effort deletion of related resources.
_ = dyn.Resource(HTTPTrigGVR).Namespace(ns).Delete(ctx, name+"-route", metav1.DeleteOptions{})
fn, err := dyn.Resource(FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("get function for deletion: %w", err)
}
if err == nil {
if pkgName, found, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name"); found && pkgName != "" {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
}
}
if err := dyn.Resource(FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("delete function: %w", err)
}
if fn != nil {
if envName, found, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name"); found && envName != "" {
CleanupEnvironmentIfUnused(context.Background(), dyn, ns, envName)
}
}
return nil
}
func parseTTL(ttl string) (time.Time, error) {
duration, err := time.ParseDuration(ttl)
if err != nil {
return time.Time{}, err
}
return time.Now().UTC().Add(duration), nil
}
-22
View File
@@ -1,22 +0,0 @@
package runtime
// BuildDeployArchive creates a deployment archive for a given language and code.
// For Node.js, it creates a zip with an ESM wrapper.
// For script languages (PHP, Ruby), it creates a zip with the correct entrypoint filename.
// For others, it returns the code as is.
func BuildDeployArchive(lang, code string) ([]byte, error) {
switch lang {
case "nodejs":
return BuildJSDeployZip(code)
case "php":
return BuildScriptZip(code, "main.php")
case "ruby":
return BuildScriptZip(code, "handler.rb")
case "python", "go", "perl": // Go is a special case handled in CreateFunction, but for archive purposes it's just the source.
return []byte(code), nil
default:
// Allow unknown languages but treat them as simple scripts.
// The environment itself will fail later if the language is truly unsupported.
return []byte(code), nil
}
}
+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 {
+30 -1219
View File
File diff suppressed because it is too large Load Diff
+59 -1
View File
@@ -1,4 +1,32 @@
// AI-функции (линтер, генерация, объяснение)
/* 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();
@@ -28,6 +56,36 @@ async function aiCheck(codeId, langId, resultId) {
}
}
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';
+8 -1
View File
@@ -1,4 +1,11 @@
// API-вызовы и авторизация
/* 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() });
+7 -418
View File
@@ -1,269 +1,4 @@
// Главный модуль приложения 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('<?php') === 0) {
var rest = body.slice(5);
rest = rest.replace(/^\r?\n/, '');
return '<?php\n' + warning + '\n' + rest;
}
return warning + '\n' + body;
}
function httpTriggerByFn(fnName) {
// Функция перемещена в triggers.js
}
function timeTriggerByFn(fnName) {
// Функция перемещена в triggers.js
}
function toggleScheduleFields(prefix) {
// Функция перемещена в triggers.js
}
function schedulePayload(prefix) {
// Функция перемещена в triggers.js
}
async function syncScheduleForFunction(name, prefix) {
// Функция перемещена в triggers.js
}
function h(v) {
// Функция перемещена в ui.js
return String(v == null ? '' : v);
}
function formatTimestamp(ts) {
// Функция перемещена в ui.js
}
function timestampCell(ts) {
// Функция перемещена в ui.js
}
const LANG_TEMPLATES = {
// Константы перемещены в config.js
};
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');
}
}
/* app.js — основная логика: загрузка данных, таблица функций */
async function reloadAll() {
try {
@@ -308,9 +43,9 @@ async function reloadAll() {
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) + '\')">\u0420\u0435\u0434.</button> ' +
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">\u0412\u044b\u0437\u043e\u0432</button> ' +
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">\u0423\u0434\u0430\u043b\u0438\u0442\u044c</button>';
'<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>' +
@@ -325,157 +60,11 @@ async function reloadAll() {
}).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');
}
}
// Инициализация и загрузка модулей
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();
+31 -9
View File
@@ -1,4 +1,4 @@
// Логика авторизации (логин/логаут, токен, email)
/* auth.js — авторизация */
function showLoginOverlay() {
document.getElementById('login-overlay').style.display = 'flex';
@@ -13,7 +13,9 @@ async function doLogin() {
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; }
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 {
@@ -26,8 +28,11 @@ async function doLogin() {
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
@@ -58,26 +63,43 @@ function jwtEmail(token) {
function setUserAvatar(token) {
var el = document.getElementById('user-avatar');
if (!el) return;
var email = token ? jwtEmail(token) : null;
var email = localStorage.getItem('auth_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';
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.fontSize = '';
el.style.minWidth = '';
el.style.padding = '';
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();
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Константы и настройки
/* config.js — константы и шаблоны языков */
const LANG_TEMPLATES = {
python: {
+89 -3
View File
@@ -1,4 +1,15 @@
// CRUD-операции с функциями (создание, редактирование, удаление)
/* 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;
@@ -56,11 +67,86 @@ async function submitCreate() {
}
closeCreate();
progress.stop('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok');
progress.stop('Функция создана: ' + 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');
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');
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
// Инициализация окружения
/* init.js — инициализация namespace, утилиты таймера и статуса */
// Init polling
var _initPolling = false;
var _initTickBusy = false;
var _initStartedAt = 0;
@@ -136,7 +135,7 @@ async function pollNSStatus() {
setBox('init-error', '');
initLog('Запуск инициализации...');
var attempt = 0;
var maxAttempts = 60;
var maxAttempts = 60; // 5 минут
var interval = setInterval(async function () {
if (_initTickBusy) return;
_initTickBusy = true;
+1 -76
View File
@@ -1,43 +1,4 @@
// Модальные окна
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;
}
/* modals.js — управление модальными окнами Help и Invoke */
function openHelp() {
document.getElementById('help-modal').classList.add('open');
@@ -47,29 +8,6 @@ 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;
@@ -136,16 +74,3 @@ async function submitInvoke() {
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');
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// HTTP и Cron триггеры
/* triggers.js — HTTP и Cron триггеры */
function httpTriggerByFn(fnName) {
return (S.httpTriggers || []).find(function (t) {
+5 -35
View File
@@ -1,4 +1,8 @@
// UI-вспомогательные функции
/* ui.js — UI-вспомогательные функции */
function setText(id, value) {
document.getElementById(id).textContent = String(value);
}
function showStatus(message, kind) {
const el = document.getElementById('status');
@@ -14,10 +18,6 @@ function clearStatus() {
el.textContent = '';
}
function setText(id, value) {
document.getElementById(id).textContent = String(value);
}
function h(v) {
return String(v == null ? '' : v)
.replaceAll('&', '&amp;')
@@ -45,33 +45,3 @@ function timestampCell(ts) {
var text = formatTimestamp(ts);
return '<span class="mono" title="' + h(ts || '') + '">' + h(text) + '</span>';
}
// 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;
}
@@ -1 +0,0 @@
<!-- AI Assistant -->
@@ -1 +0,0 @@
<!-- Navbar -->
@@ -1 +0,0 @@
<!-- Status cards -->
@@ -1 +0,0 @@
<!-- Модальное окно создания функции -->
@@ -1 +0,0 @@
<!-- Модальное окно редактирования функции -->
-1
View File
@@ -1 +0,0 @@
<!-- Модальное окно помощи -->
@@ -1 +0,0 @@
<!-- Модальное окно инициализации -->
-1
View File
@@ -1 +0,0 @@
<!-- Модальное окно вызова функции -->
-32
View File
@@ -1,32 +0,0 @@
<!-- Модальное окно логина -->
<div id="login-overlay"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:999; align-items:center; justify-content:center; padding:16px;">
<div class="panel" style="width:min(440px,100%);">
<div class="brand" style="margin-bottom:24px;">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Стенд</label>
<select id="l-env"
style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px;">
<option value="dev">Dev</option>
<option value="test" selected>Test</option>
<option value="prod">Prod</option>
</select>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Токен</label>
<textarea id="l-token" rows="5"
style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px; font-family:monospace; font-size:12px; resize:vertical; width:100%; box-sizing:border-box;"
placeholder="Введите токен..."></textarea>
<div style="font-size:11px; color:var(--text-secondary); margin-top:4px;">Токен: <strong
style="color:#8bc7ff;">Личный кабинет → Профиль пользователя → Токены</strong></div>
</div>
<div id="l-error" style="display:none; color:#ff6b6b; font-size:13px; margin-bottom:10px;"></div>
<button id="l-btn" class="btn" style="width:100%;" onclick="doLogin()">Войти</button>
</div>
</div>