Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a4eea50b | ||
|
|
81367f7a5d | ||
|
|
7ebdaf743c | ||
|
|
eb20b9f28d | ||
|
|
7be2039491 | ||
|
|
69d71f5e05 | ||
|
|
8a8a2e694d | ||
|
|
ef1256ca73 | ||
|
|
a034c38a38 | ||
|
|
1f1afa95e1 | ||
|
|
4960bf0ee2 | ||
|
|
bef4a545d9 | ||
|
|
ff29177aef | ||
|
|
d61d68f09b | ||
|
|
38a3c36768 | ||
|
|
3db402925a | ||
|
|
85ffad3772 | ||
|
|
3e7d08894d | ||
|
|
d21e1ac9a5 |
@@ -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
@@ -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` и др.) — только после явного подтверждения с указанием конкретных объектов
|
||||
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 и возвращает время истечения.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: Что делает?';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 + ' ' + 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '—';
|
||||
var d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function timestampCell(ts) {
|
||||
var text = formatTimestamp(ts);
|
||||
return '<span class="mono" title="' + h(ts || '') + '">' + h(text) + '</span>';
|
||||
}
|
||||
@@ -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() — загрузить функции
|
||||
```
|
||||
@@ -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
|
||||
- Документировать пайплайн и типовые ошибки
|
||||
@@ -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)}
|
||||
@@ -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
@@ -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 "========================================"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user