Compare commits

...
Author SHA1 Message Date
“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
Naeel d21e1ac9a5 feat: restore cron page, email in navbar, metrics pipeline v1.3.19 2026-05-01 19:53:40 +03:00
33 changed files with 2679 additions and 1580 deletions
+54 -11
View File
@@ -1,18 +1,61 @@
# Правила
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
- Вопрос → короткий ответ → СТОП. Не рассуждать, не исследовать без команды.
- НЕ делать ничего "попутно" без явной просьбы. Сделал — стоп — ждёшь команды.
- Без приветствий и лишних рассуждений. Код — только по запросу. Не бормотать.
- Вопрос → короткий ответ → СТОП.
- Ничего лишнего.
- Код — только по запросу.
> Подробные правила: [`.github/pravila.md`](pravila.md)
## ⛔⛔⛔ ВОПРОС = СТОП
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
1. ТОЛЬКО ответить на вопрос
2. ОСТАНОВИТЬСЯ
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
1. Не трогать рабочий код без явного указания.
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
Перед любым запуском команд проверять, что локальные файлы и содержимое на ВМ синхронизированы; при сомнении ориентироваться на состояние файлов на ВМ.
3. Все команды — **только через SSH**, никогда локально:
```bash
2. Файлы редактируются локально:
~/fission
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \
naeel@5.172.178.213:~/terra/fission/
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 'КОМАНДА'
```
4. **ЗАПРЕЩЕНО** откатывать код, версии, образы и рабочее состояние без явной команды пользователя.
5. После каждого исправления делать отдельный commit через SSH.
- не выполнять инфраструктурные команды локально
- не открывать интерактивные сессии
- не делать цепочки без необходимости
4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена.
5. ЗАПРЕЩЕНО:
- откатывать код
- менять версии
- ломать рабочее состояние
6. После каждого исправления:
- git add/commit ЛОКАЛЬНО
- затем синхронизация (rsync) на ВМ
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:**
- Не расширять масштаб работы
- Не выполнять "логичные следующие шаги"
- Не инициировать дополнительные операции
- Не делать ничего кроме того что сказано
**Пример (2026-05-01):**
- Приказано: "собери"
- Сделано: ✓ собрал образы v1.3.17 и v0.1.2
- СТОП — жду команды дальше
- **ЗАПРЕЩЕНО:** обновлять манифесты, заливать образы, применять на кластер, запускать тесты
**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты")
+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` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
+12 -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,15 @@ func main() {
log.Fatalf("create dynamic client: %v", err)
}
testMode := os.Getenv("FISSION_TEST_MODE") == "true"
var authenticator auth.Authenticator
if testMode {
authenticator = &auth.TestAuthenticator{}
} else {
authenticator = auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil)
}
srv := api.NewServer(api.Config{
Dyn: dyn,
Namespace: namespace,
@@ -45,7 +55,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.12
image: naeel/fission-console:v1.3.25
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")
}
}
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type CronMetric struct {
Timestamp string `json:"timestamp"`
Source string `json:"source"`
Hostname string `json:"hostname,omitempty"`
Status string `json:"status,omitempty"`
MemoryTotalMB float64 `json:"memory_total_mb,omitempty"`
MemoryFreeMB float64 `json:"memory_free_mb,omitempty"`
MemoryAvailMB float64 `json:"memory_available_mb,omitempty"`
MemoryUsedMB float64 `json:"memory_used_mb,omitempty"`
MemoryPercent float64 `json:"memory_percent,omitempty"`
CpuLoad1m float64 `json:"cpu_load_1m,omitempty"`
CpuLoad5m float64 `json:"cpu_load_5m,omitempty"`
CpuLoad15m float64 `json:"cpu_load_15m,omitempty"`
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskFreeGB float64 `json:"disk_free_gb,omitempty"`
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
UptimeSec float64 `json:"uptime_sec,omitempty"`
Notes string `json:"notes,omitempty"`
}
var (
cronMetricMu sync.RWMutex
cronMetricHistory []CronMetric
cronMetricMaxKeep = 240
)
func (s *Server) handleCronMetrics(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cronMetricMu.RLock()
history := append([]CronMetric(nil), cronMetricHistory...)
cronMetricMu.RUnlock()
latest := CronMetric{}
if len(history) > 0 {
latest = history[len(history)-1]
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"count": len(history),
"latest": latest,
"history": history,
})
case http.MethodPost:
if !allowCronIngest(r) {
writeJSONError(w, http.StatusUnauthorized, "invalid cron token")
return
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json payload")
return
}
sample := normalizeCronMetric(payload)
cronMetricMu.Lock()
cronMetricHistory = append(cronMetricHistory, sample)
if len(cronMetricHistory) > cronMetricMaxKeep {
cronMetricHistory = append([]CronMetric(nil), cronMetricHistory[len(cronMetricHistory)-cronMetricMaxKeep:]...)
}
count := len(cronMetricHistory)
cronMetricMu.Unlock()
writeAnyJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count, "latest": sample})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func allowCronIngest(r *http.Request) bool {
expected := strings.TrimSpace(os.Getenv("CRON_TOKEN"))
if expected == "" {
return true
}
return strings.TrimSpace(r.Header.Get("X-Cron-Token")) == expected
}
func normalizeCronMetric(payload map[string]any) CronMetric {
now := time.Now().UTC().Format(time.RFC3339)
metric := CronMetric{
Timestamp: now,
Source: textValue(payload, "source", "function", "name", "host", "hostname", "instance"),
Hostname: textValue(payload, "hostname", "host", "node", "instance"),
Status: textValue(payload, "status"),
Notes: textValue(payload, "notes", "note", "message"),
}
if metric.Source == "" {
metric.Source = "cron"
}
metric.MemoryTotalMB = numberValue(payload, "memory_total_mb", "total_memory_mb", "mem_total_mb", "mem_total")
metric.MemoryFreeMB = numberValue(payload, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb")
metric.MemoryAvailMB = numberValue(payload, "memory_available_mb", "available_memory_mb", "mem_available_mb", "avail_memory_mb")
metric.MemoryUsedMB = numberValue(payload, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb")
metric.MemoryPercent = numberValue(payload, "memory_percent", "mem_percent", "memory_usage_percent")
if metric.MemoryUsedMB == 0 && metric.MemoryTotalMB > 0 && metric.MemoryFreeMB > 0 {
metric.MemoryUsedMB = metric.MemoryTotalMB - metric.MemoryFreeMB
}
if metric.MemoryAvailMB == 0 && metric.MemoryFreeMB > 0 {
metric.MemoryAvailMB = metric.MemoryFreeMB
}
if metric.MemoryPercent == 0 && metric.MemoryTotalMB > 0 && metric.MemoryUsedMB > 0 {
metric.MemoryPercent = (metric.MemoryUsedMB / metric.MemoryTotalMB) * 100
}
metric.CpuLoad1m = numberValue(payload, "cpu_load_1m", "loadavg_1m", "load_1m", "load1")
metric.CpuLoad5m = numberValue(payload, "cpu_load_5m", "loadavg_5m", "load_5m", "load5")
metric.CpuLoad15m = numberValue(payload, "cpu_load_15m", "loadavg_15m", "load_15m", "load15")
metric.DiskTotalGB = numberValue(payload, "disk_total_gb", "total_disk_gb", "disk_total_mb", "disk_total")
metric.DiskFreeGB = numberValue(payload, "disk_free_gb", "free_disk_gb", "disk_free_mb", "disk_free")
metric.DiskUsedGB = numberValue(payload, "disk_used_gb", "used_disk_gb", "disk_used_mb", "disk_used")
if metric.DiskUsedGB == 0 && metric.DiskTotalGB > 0 && metric.DiskFreeGB > 0 {
metric.DiskUsedGB = metric.DiskTotalGB - metric.DiskFreeGB
}
if uptime := numberValue(payload, "uptime_sec", "uptime", "uptime_seconds"); uptime > 0 {
metric.UptimeSec = uptime
}
if ts := textValue(payload, "timestamp", "ts", "collected_at"); ts != "" {
metric.Timestamp = ts
}
return metric
}
func textValue(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := payload[key]; ok {
text := strings.TrimSpace(toString(value))
if text != "" {
return text
}
}
}
return ""
}
func numberValue(payload map[string]any, keys ...string) float64 {
for _, key := range keys {
if value, ok := payload[key]; ok {
if number, ok := toFloat64(value); ok {
return number
}
}
}
return 0
}
func toString(value any) string {
switch v := value.(type) {
case string:
return v
case []byte:
return string(v)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case int:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case json.Number:
return v.String()
default:
return ""
}
}
func toFloat64(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case json.Number:
f, err := v.Float64()
return f, err == nil
case string:
if strings.TrimSpace(v) == "" {
return 0, false
}
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err == nil {
return n, true
}
return 0, false
default:
return 0, false
}
}
+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 и возвращает время истечения.
+16 -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),
@@ -127,6 +120,15 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// Auth: не требует токена — сам проверяет и возвращает namespace
mux.HandleFunc("/console/api/auth", s.handleAuth)
// Cron: публичные маршруты для метрик
cronHandler := ui.CronHandler()
mux.Handle("/cron", cronHandler)
mux.Handle("/cron/", cronHandler)
mux.Handle("/console/cron", cronHandler)
mux.Handle("/console/cron/", cronHandler)
mux.HandleFunc("/cron/api/metrics", s.handleCronMetrics)
mux.HandleFunc("/console/cron/api/metrics", s.handleCronMetrics)
// Приватные: требуют X-Auth-Token / X-Test-Sub
auth := s.authMiddleware
@@ -265,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
}
+174
View File
@@ -0,0 +1,174 @@
// Package auth реализует слой аутентификации консоли.
//
// Сервер получает UserIdentity (Sub + Email) и больше не знает
// ни про Deck API, ни про JWT подписи, ни про тест/прод режимы.
package auth
import (
"context"
"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
}
+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")
}
}
+16
View File
@@ -0,0 +1,16 @@
package ui
import (
_ "embed"
"net/http"
)
//go:embed cron.html
var cronPage string
func CronHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(cronPage))
})
}
+430
View File
@@ -0,0 +1,430 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NUBES Cron</title>
<style>
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001c34;
--border: #0b2d50;
--accent: #1a7fd4;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
--good: #56d364;
--warn: #e3b341;
--bad: #f85149;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: linear-gradient(180deg, #001120 0%, #061424 100%);
color: var(--text-primary);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.navbar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--bg-navbar);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff; display: grid; place-items: center;
font-size: 14px; font-weight: 800; letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.08; }
.brand-text .nubes { font-size: 12px; letter-spacing: .22em; font-weight: 800; color: #8bc7ff; }
.brand-text .product { font-size: 13px; font-weight: 700; letter-spacing: .04em; }
.btn {
background: var(--accent); color: #fff; border: 0; border-radius: 6px;
padding: 8px 12px; cursor: pointer; font-size: 13px; line-height: 1.2;
}
.btn.ghost { background: #0c2b49; }
.btn:hover { opacity: .95; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 20px; }
.hero {
background: linear-gradient(180deg, #001929 0%, #001622 100%);
border: 1px solid var(--border); border-radius: 14px; padding: 18px;
box-shadow: 0 12px 34px rgba(0, 0, 0, .18);
}
.hero-top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.hero h1 { font-size: 28px; margin-bottom: 6px; }
.hero p { color: var(--text-secondary); max-width: 920px; line-height: 1.5; font-size: 14px; }
.hero .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.clock {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(12, 43, 73, .72);
border: 1px solid #1d486d;
color: #d7e7f7;
font-size: 14px;
font-weight: 700;
letter-spacing: .02em;
}
.clock small {
display: block;
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 2px;
}
.grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;
margin-top: 16px;
}
.card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px;
}
.k { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.v { font-size: 24px; font-weight: 800; margin-top: 6px; }
.s { font-size: 12px; color: var(--text-secondary); margin-top: 4px; min-height: 16px; }
.panel {
margin-top: 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.panel-head {
display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.panel-head h2 { font-size: 16px; }
.meta { color: var(--text-secondary); font-size: 12px; }
table { width: 100%; border-collapse: collapse; min-width: 1220px; }
th, td { padding: 9px 11px; border-bottom: 1px solid #0b2a48; text-align: left; }
th {
color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
}
td { font-size: 13px; vertical-align: middle; }
tbody tr:hover { background: rgba(26, 127, 212, .08); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.status-ok { color: var(--good); }
.status-warn { color: var(--warn); }
.status-bad { color: var(--bad); }
.empty { color: var(--text-secondary); padding: 24px 12px; text-align: center; }
.pagination {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 12px 16px; border-top: 1px solid var(--border);
}
.page-btn {
min-width: 32px; height: 28px; padding: 0 8px; border-radius: 6px;
background: #0c2b49; border: 1px solid #1d486d; color: #d7e7f7;
font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.page-btn:hover { background: #153d68; }
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.page-btn:disabled { opacity: .35; cursor: default; }
.page-info { color: var(--text-secondary); font-size: 12px; margin-left: 6px; }
.footer {
padding-top: 12px; color: var(--text-secondary); font-size: 12px; line-height: 1.5;
}
.badge {
display: inline-block; padding: 2px 7px; border-radius: 999px; background: #0c2b49; border: 1px solid #1d486d;
color: #d7e7f7; font-size: 11px;
}
@media (max-width: 820px) {
.hero h1 { font-size: 22px; }
}
</style>
</head>
<body>
<div class="navbar">
<div class="brand">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">МЕТРИКИ CRON</div>
</div>
</div>
<div class="actions">
<button class="btn ghost" id="btn-refresh">Обновить</button>
<a class="btn ghost" href="/console/">Консоль</a>
</div>
</div>
<div class="wrap">
<section class="hero">
<div class="hero-top">
<div>
<h1>Снимки состояния по cron</h1>
<p>
Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime.
Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица.
<br><br>
<strong>Примечание:</strong> метрики читаются из <span class="mono">/proc</span> внутри pod-а и отражают состояние
<em>ноды кластера</em>, на которой запущена функция — не ресурсы самого pod-а и не агрегированные показатели кластера.
</p>
<div class="clock">
<div>
<small>Current time</small>
<span id="current-time">--:--:--</span>
</div>
</div>
</div>
<div class="actions">
<span class="badge" id="points-badge">0 points</span>
</div>
</div>
<div class="grid" id="summary"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>История</h2>
<div class="meta" id="last-updated">Ожидание первых данных…</div>
</div>
<div class="meta mono" id="latest-source"></div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Время</th>
<th>Источник</th>
<th>Хост</th>
<th>Своб. МБ</th>
<th>Дост. МБ</th>
<th>Исп. МБ</th>
<th>Память %</th>
<th>ЦП 1м</th>
<th>ЦП 5м</th>
<th>ЦП 15м</th>
<th>Диск своб.</th>
<th>Аптайм</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td class="empty" colspan="13">Данных пока нет</td></tr>
</tbody>
</table>
</div>
<div id="pagination" class="pagination"></div>
</section>
<div class="footer">
Endpoint для cron-функции: <span class="mono">POST /cron/api/metrics</span>.<br>
Пример полезной нагрузки: <span class="mono">{"source":"my-fn","memory_free_mb":1234,"cpu_load_1m":0.12}</span>.
</div>
</div>
<script>
const API = '/cron/api/metrics';
const summaryEl = document.getElementById('summary');
const historyBody = document.getElementById('history-body');
const lastUpdatedEl = document.getElementById('last-updated');
const latestSourceEl = document.getElementById('latest-source');
const pointsBadge = document.getElementById('points-badge');
const currentTimeEl = document.getElementById('current-time');
function num(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function fmtNum(v, d = 1, suffix = '') {
const n = num(v);
if (n === null) return '—';
return n.toFixed(d) + suffix;
}
function fmtTime(v) {
if (!v) return '—';
const d = new Date(v);
if (Number.isNaN(d.getTime())) return String(v);
return new Intl.DateTimeFormat('ru-RU', {
year: '2-digit', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(d);
}
function updateClock() {
currentTimeEl.textContent = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(new Date());
}
function fmtUptime(v) {
const sec = num(v);
if (sec === null) return '—';
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
const mins = Math.floor((sec % 3600) / 60);
if (days > 0) return `${days}д ${hours}ч ${mins}м`;
if (hours > 0) return `${hours}ч ${mins}м`;
return `${Math.floor(sec)}с`;
}
function cls(status) {
const s = String(status || '').toLowerCase();
if (s.includes('err') || s.includes('fail') || s.includes('bad')) return 'status-bad';
if (s.includes('warn') || s.includes('degrad') || s.includes('idle')) return 'status-warn';
return 'status-ok';
}
function card(label, value, subtitle) {
return `
<div class="card">
<div class="k">${label}</div>
<div class="v">${value}</div>
<div class="s">${subtitle || ''}</div>
</div>
`;
}
function renderSummary(latest, count) {
if (!latest) {
summaryEl.innerHTML = [
card('Снимки', '0', 'Ожидание cron-запросов'),
card('Своб. память', '—', 'МБ'),
card('Дост. память', '—', 'МБ'),
card('Исп. память', '—', 'МБ'),
card('Нагрузка ЦП', '—', '1м / 5м / 15м'),
card('Диск своб.', '—', 'ГБ')
].join('');
pointsBadge.textContent = '0 снимков';
return;
}
summaryEl.innerHTML = [
card('Снимки', String(count), `Источник: ${latest.source || 'cron'}`),
card('Своб. память', fmtNum(latest.memory_free_mb, 1, ' МБ'), latest.hostname || '—'),
card('Дост. память', fmtNum(latest.memory_available_mb, 1, ' МБ'), 'Доступная RAM'),
card('Исп. память', fmtNum(latest.memory_used_mb, 1, ' МБ'), `Память ${fmtNum(latest.memory_percent, 1, '%')}`),
card('Нагрузка ЦП', [fmtNum(latest.cpu_load_1m, 2), fmtNum(latest.cpu_load_5m, 2), fmtNum(latest.cpu_load_15m, 2)].join(' / '), '1м / 5м / 15м'),
card('Диск своб.', fmtNum(latest.disk_free_gb, 1, ' ГБ'), `Аптайм ${fmtUptime(latest.uptime_sec)}`)
].join('');
pointsBadge.textContent = `${count} снимк${count === 1 ? '' : 'ов'}`;
}
const PAGE_SIZE = 10;
let allHistory = [];
let currentPage = 1;
function renderPage(page) {
const total = allHistory.length;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), pages);
const pg = document.getElementById('pagination');
if (!total) {
historyBody.innerHTML = '<tr><td class="empty" colspan="13">Данных пока нет</td></tr>';
pg.innerHTML = '';
return;
}
const start = (currentPage - 1) * PAGE_SIZE;
const slice = allHistory.slice(start, start + PAGE_SIZE);
historyBody.innerHTML = slice.map(function (item) {
return `
<tr>
<td class="mono">${fmtTime(item.timestamp)}</td>
<td>${item.source || '—'}</td>
<td>${item.hostname || '—'}</td>
<td class="mono">${fmtNum(item.memory_free_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_available_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_used_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_percent, 1, '%')}</td>
<td class="mono">${fmtNum(item.cpu_load_1m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_5m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_15m, 2)}</td>
<td class="mono">${fmtNum(item.disk_free_gb, 1, ' ГБ')}</td>
<td class="mono">${fmtUptime(item.uptime_sec)}</td>
<td class="${cls(item.status)}">${item.status || 'ok'}</td>
</tr>
`;
}).join('');
if (pages <= 1) { pg.innerHTML = ''; return; }
let btns = `<button class="page-btn" id="pg-prev" ${currentPage === 1 ? 'disabled' : ''}>←</button>`;
for (let i = 1; i <= pages; i++) {
btns += `<button class="page-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
btns += `<button class="page-btn" id="pg-next" ${currentPage === pages ? 'disabled' : ''}>→</button>`;
btns += `<span class="page-info">стр. ${currentPage} из ${pages} · всего ${total}</span>`;
pg.innerHTML = btns;
pg.querySelector('#pg-prev').addEventListener('click', function () { renderPage(currentPage - 1); });
pg.querySelector('#pg-next').addEventListener('click', function () { renderPage(currentPage + 1); });
pg.querySelectorAll('[data-page]').forEach(function (btn) {
btn.addEventListener('click', function () { renderPage(Number(btn.dataset.page)); });
});
}
function renderTable(history) {
allHistory = history.slice().reverse();
renderPage(1);
}
function updateMeta(history) {
if (!history.length) {
lastUpdatedEl.textContent = 'Ожидание первых данных…';
latestSourceEl.textContent = '';
return;
}
const latest = history[history.length - 1];
lastUpdatedEl.textContent = `Последний снимок: ${fmtTime(latest.timestamp)}${latest.hostname ? ' · ' + latest.hostname : ''}`;
latestSourceEl.textContent = latest.source ? `source=${latest.source}` : '';
}
async function loadMetrics() {
try {
const res = await fetch(API, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const history = Array.isArray(data.history) ? data.history : [];
const latest = data.latest || (history.length ? history[history.length - 1] : null);
renderSummary(latest, history.length || (latest ? 1 : 0));
renderTable(history);
updateMeta(history);
} catch (err) {
summaryEl.innerHTML = [
card('Снимки', '—', 'Ошибка загрузки'),
card('Своб. память', '—', err.message),
card('Дост. память', '—', 'Проверить backend'),
card('Исп. память', '—', 'Проверить cron-функцию'),
card('Нагрузка ЦП', '—', 'Проверить backend'),
card('Диск своб.', '—', 'Проверить приём данных')
].join('');
historyBody.innerHTML = `<tr><td class="empty" colspan="13">Ошибка загрузки: ${err.message}</td></tr>`;
lastUpdatedEl.textContent = 'Ошибка обновления';
latestSourceEl.textContent = '';
pointsBadge.textContent = '0 снимков';
}
}
document.getElementById('btn-refresh').addEventListener('click', loadMetrics);
updateClock();
setInterval(updateClock, 1000);
loadMetrics();
setInterval(loadMetrics, 30000);
</script>
</body>
</html>
+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
//go:embed index.html cron.html css js
var content embed.FS
func Handler() http.Handler {
+16 -1174
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');
}
}
+99
View File
@@ -0,0 +1,99 @@
/* 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; }
btn.disabled = true;
errEl.style.display = 'none';
try {
const res = await fetch(API_BASE + '/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token, env: env })
});
if (!res.ok) {
const d = await res.json().catch(function () { return {}; });
throw new Error(d.error || 'Ошибка входа');
}
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_env', env);
setUserAvatar(token);
hideLoginOverlay();
// Проверяем статус NS — если не ready, показываем init overlay
try {
var sr = await fetch(API_BASE + '/ns/status', { headers: { 'X-Auth-Token': token, 'X-Auth-Env': env } });
var sd = await sr.json();
if (!sd.ready) {
pollNSStatus();
return;
}
} catch (_) { }
reloadAll();
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
} finally {
btn.disabled = false;
}
}
function jwtEmail(token) {
try {
var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
return payload.email || payload.sub || null;
} catch (_) { return null; }
}
function setUserAvatar(token) {
var el = document.getElementById('user-avatar');
if (!el) return;
var email = token ? jwtEmail(token) : null;
if (email) {
el.textContent = email;
el.title = email;
el.style.fontSize = '0.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');
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() — загрузить функции
```
+36
View File
@@ -0,0 +1,36 @@
# План интеграции загрузки функций из архивов в UI Fission Console
## Цель
Добавить в web-интерфейс Fission Console возможность загружать функцию в виде архива (zip/tar), аналогично `fission function create --deploy ...`.
## Основные шаги
1. UI: добавить компонент для выбора и загрузки архива (drag&drop или file input).
2. Backend: реализовать обработку архива, валидацию структуры, запуск линтеров и (опционально) AI-проверку.
3. Интеграция с Fission API: отправка архива через REST (multipart/form-data) для создания/обновления функции.
4. UI: отображение статуса загрузки, ошибок, логов билда.
## Валидация архива
- Проверка наличия entrypoint-файла (main.py/handler.js и т.д.)
- Проверка структуры (отсутствие мусора, секретов)
- Линтеры (flake8/pylint/go vet и т.д.)
- (Опционально) AI-проверка через облачный LLM
## Безопасность
- Ограничить размер архива
- Фильтрация запрещённых файлов
- Логирование действий
## Синхронизация с Terraform/CLI
- Архивы, деплоенные через UI, CLI, Terraform — одинакового формата
- UI всегда отображает актуальный список функций из Fission API
## Возможные сложности
- Ошибки структуры архива (нет entrypoint)
- Долгая сборка/деплой больших архивов
- Drift при одновременном управлении через Terraform и UI
## Следующие шаги
- Реализовать UI-компонент для загрузки архива
- Прототип backend-валидации
- Интеграция с Fission API
- Документировать пайплайн и типовые ошибки
+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
@@ -0,0 +1,73 @@
import os
import urllib.request
import urllib.error
import json
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
def main():
payload = {
"source": "fission-cron-metrics",
"hostname": os.uname().nodename,
"status": "ok",
}
# CPU load
try:
load = os.getloadavg()
payload["cpu_load_1m"] = load[0]
payload["cpu_load_5m"] = load[1]
payload["cpu_load_15m"] = load[2]
except Exception:
pass
# Memory
try:
with open("/proc/meminfo") as f:
meminfo = {}
for line in f:
k, v = line.split(":")
meminfo[k.strip()] = int(v.split()[0])
total = meminfo.get("MemTotal", 0) / 1024
free = meminfo.get("MemFree", 0) / 1024
avail = meminfo.get("MemAvailable", 0) / 1024
used = total - free
payload["memory_total_mb"] = round(total, 1)
payload["memory_free_mb"] = round(free, 1)
payload["memory_available_mb"] = round(avail, 1)
payload["memory_used_mb"] = round(used, 1)
payload["memory_percent"] = round(used / total * 100, 1) if total else 0
except Exception:
pass
# Disk
try:
st = os.statvfs("/")
total_gb = st.f_blocks * st.f_frsize / 1024**3
free_gb = st.f_bfree * st.f_frsize / 1024**3
used_gb = total_gb - free_gb
payload["disk_total_gb"] = round(total_gb, 1)
payload["disk_free_gb"] = round(free_gb, 1)
payload["disk_used_gb"] = round(used_gb, 1)
except Exception:
pass
# Uptime
try:
with open("/proc/uptime") as f:
payload["uptime_sec"] = float(f.read().split()[0])
except Exception:
pass
data = json.dumps(payload).encode()
req = urllib.request.Request(
COLLECTOR_URL,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return {"ok": True, "status": resp.status}
except urllib.error.URLError as e:
return {"ok": False, "error": str(e)}
-50
View File
@@ -158,21 +158,6 @@ sub main {
EOF
}
write_go() {
local dir=$1
mkdir -p "${dir}"
cat > "${dir}/handler.go" <<'EOF'
package main
import (
"encoding/json"
"net/http"
)
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"msg": "hello-go"})
}
EOF
cat > "${dir}/go.mod" <<'EOF'
module fn
@@ -260,41 +245,6 @@ kube_exists "environments.fission.io" "simple-python-env" \
tf_destroy "${D}" | grep -q "Destroy complete" && pass "T02: destroy OK" || fail "T02: destroy FAILED"
kube_gone "functions.fission.io" "${T02}" && pass "T02: Function удалена" || fail "T02: Function осталась"
# ════════════════════════════════════════════════════════════════════════
echo ""
echo ">>> T03: go — source pipeline, create с URL"
# ════════════════════════════════════════════════════════════════════════
T03="${RUN_ID}-go-basic"
D=$(setup_workdir "t03")
write_provider "${D}"
write_go "${D}/code"
cat >> "${D}/main.tf" <<EOF
resource "fission_simple_function" "fn" {
name = "${T03}"
runtime = "go"
code_dir = "\${path.module}/code"
url = "/test/${T03}"
methods = ["GET", "POST"]
}
EOF
tf_apply "${D}" | grep -q "Apply complete" \
&& pass "T03: go apply OK" || fail "T03: go apply FAILED"
kube_exists "functions.fission.io" "${T03}" && pass "T03: Function создана" || fail "T03: Function не найдена"
kube_exists "packages.fission.io" "${T03}-pkg" && pass "T03: Package создан" || fail "T03: Package не найден"
kube_exists "httptriggers.fission.io" "${T03}-trigger" && pass "T03: Trigger создан" || fail "T03: Trigger не найден"
kube_exists "environments.fission.io" "simple-go-env" && pass "T03: simple-go-env OK" || fail "T03: simple-go-env не найден"
# Проверяем что Package имеет source (не deployment)
PKG_SOURCE=$(kubectl get packages.fission.io -n "${NAMESPACE}" "${T03}-pkg" \
-o jsonpath='{.spec.source.type}' 2>/dev/null || echo "")
[ "${PKG_SOURCE}" = "literal" ] \
&& pass "T03: Package.spec.source.type=literal (source deploy)" \
|| fail "T03: Package.spec.source.type='${PKG_SOURCE}' (ожидалось literal)"
tf_destroy "${D}" | grep -q "Destroy complete" && pass "T03: destroy OK" || fail "T03: destroy FAILED"
kube_gone "functions.fission.io" "${T03}" && pass "T03: Function удалена" || fail "T03: Function осталась"
# ════════════════════════════════════════════════════════════════════════
echo ""
echo ">>> T04: ruby — create с URL"
-34
View File
@@ -10,7 +10,6 @@ RUN_ID=$(echo "$RUN_ID" | sha256sum 2>/dev/null | head -c 8 || echo "${RUN_ID}"
cleanup_namespaces() {
local subs=()
subs+=("${GO_NS}")
subs+=("alice@test.local" "routetest-alice@test.local" "routetest-bob@test.local")
subs+=("val-user@test.local" "dup-user@test.local" "upd-user@test.local" "get-user@test.local")
subs+=("ns-u1@test.local" "ns-u2@test.local" "alice-del@test.local" "carol-del@test.local")
@@ -38,12 +37,6 @@ cleanup_namespaces() {
}
trap cleanup_namespaces EXIT
# GO_NS фиксированный — namespace создаётся один раз, Fission не рестартует при повторных прогонах.
# Первый прогон занимает ~5 мин (rolling restart Fission), последующие — мгновенно.
GO_NS="go-livetest@test.local"
# Триггерим ensureUserNamespace заранее — Fission начнёт регистрировать NS пока идут остальные тесты.
curl -s -o /dev/null -X GET ${BASE}/functions -H "X-Test-Sub: ${GO_NS}" || true
pass() { PASS=$((PASS+1)); echo " PASS: $1"; }
fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }
check_http() { local got=$1 want=$2 label=$3; [ "$got" = "$want" ] && pass "$label (HTTP $got)" || fail "$label --- got HTTP $got, want $want"; }
@@ -783,33 +776,6 @@ check_http "$CODE" "400" "Невалидный язык cobol → 400"
# ──────────────────────────────────────────────
echo ""
echo ">>> БЛОК AE: Go — создание и вызов"
# Go: namespace GO_NS уже прогрет в начале теста (curl в самом верху).
# При первом прогоне Fission делает rolling restart (~5 мин) — retry до 600s покрывает это.
# При последующих прогонах NS уже зарегистрирован → invoke занимает ~10-15s.
GO_CODE=$(python3 -c "import json; print(json.dumps('package main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n\tfmt.Fprintf(w, \"go-ok\")\n}'))")
# Удаляем старую функцию если осталась от прошлого прогона (игнорируем ошибку)
curl -s -o /dev/null -X DELETE ${BASE}/functions/go-fn -H "X-Test-Sub: ${GO_NS}" || true
R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \
-H "X-Test-Sub: ${GO_NS}" -H "Content-Type: application/json" \
-d "{\"name\":\"go-fn\",\"language\":\"go\",\"code\":${GO_CODE},\"ttl\":\"30m\"}")
CODE=$(echo "$R" | tail -1)
check_http "$CODE" "201" "Go: create → 201"
# Retry до 600s (60 итераций × 10s): первый прогон — ждём Fission rolling restart + build + pool pod.
STATUS=""; RAW=""
for _try in $(seq 1 60); do
sleep 10
INV=$(curl -s -X POST ${BASE}/functions/go-fn/invoke \
-H "X-Test-Sub: ${GO_NS}")
STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null)
RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null)
[ "$STATUS" = "200" ] && break
done
[ "$STATUS" = "200" ] && pass "Go: invoke → 200" || fail "Go: invoke → got $STATUS (body: $RAW)"
echo "$RAW" | grep -q "go-ok" && pass "Go: тело содержит go-ok" || fail "Go: тело не содержит go-ok (got: $RAW)"
# ──────────────────────────────────────────────
echo ""
echo "========================================"
+2 -9
View File
@@ -1,10 +1,3 @@
api_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc4OTAxNTg2NywiaWF0IjoxNzczNDYzODY3LCJqdGkiOiJmYjRkMGFiNy1lYT>
# s3_uid = "332cdb0d-34bf-43bf-864d-4adcc3b556fc"
s3_uid = "332cdb0d-34bf-43bf-864d-4adcc3b556fb"
# realm = "k8s-5-sandbox-nubes-ru"
realm = "k8s-3-sandbox-nubes-ru"
#realm = "naeel-test-3"
ntazetdinov@nubes.ru
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc4OTAxNTg2Nywia
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA