diff --git a/console/cmd/server/main.go b/console/cmd/server/main.go index 7579d33..1486c6a 100644 --- a/console/cmd/server/main.go +++ b/console/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "fission-console/internal/api" "fission-console/internal/auth" "fission-console/internal/billing" + "fission-console/internal/stats" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -50,6 +51,7 @@ func main() { } billingStore := billing.NewStore() + statsProvider := stats.NewProvider() srv := api.NewServer(api.Config{ Dyn: dyn, @@ -68,6 +70,7 @@ func main() { LLMKey: os.Getenv("FISSION_LLM_KEY"), // --- end ai/ask feature --- Billing: billingStore, + Stats: statsProvider, }) // Запускаем фоновые горутины: reaper истёкших функций diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index ff59969..cf2be2c 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -55,7 +55,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.86 + image: naeel/fission-console:v1.3.87 imagePullPolicy: Always ports: - containerPort: 8090 @@ -84,6 +84,14 @@ spec: value: "http://storagesvc.fission.svc.cluster.local" - name: BILLING_DSN value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb" + - name: GRAFANA_INTERNAL_URL + value: "http://grafana.grafana.svc.cluster.local:3000" + - name: GRAFANA_PUBLIC_URL + value: "https://fission.kube5s.ru/grafana" + - name: GRAFANA_ADMIN_USER + value: "admin" + - name: GRAFANA_ADMIN_PASS + value: "GrafanaAdmin2026!" livenessProbe: httpGet: path: /health diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index 3cf34bf..e701b55 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -181,6 +181,13 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr) } + // Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно). + go func() { + if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil { + fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err) + } + }() + w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email}) } diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 77eed0f..2d80888 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -15,6 +15,7 @@ import ( "fission-console/internal/auth" "fission-console/internal/billing" "fission-console/internal/cloud" + "fission-console/internal/stats" "fission-console/internal/fission" "fission-console/ui" @@ -63,6 +64,9 @@ type Server struct { // billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан. billing billing.Store + + // stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен. + stats stats.StatsProvider } // Config содержит все параметры для создания Server. @@ -80,7 +84,8 @@ type Config struct { Authenticator auth.Authenticator // слой аутентификации LLMUrl string LLMKey string - Billing billing.Store // слой статистики (NoopStore если не задан) + Billing billing.Store // слой статистики (NoopStore если не задан) + Stats stats.StatsProvider // аналитика (NoopProvider если не настроен) } // NewServer создаёт и настраивает HTTP Server со всеми зависимостями. @@ -102,6 +107,7 @@ func NewServer(cfg Config) *Server { llmKey: cfg.LLMKey, nsManager: cloud.NewNSManager(cfg.Dyn), billing: cfg.Billing, + stats: cfg.Stats, } } @@ -165,6 +171,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction)) mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) + mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard)) mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck)) mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive)) mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive)) diff --git a/console/internal/api/stats.go b/console/internal/api/stats.go new file mode 100644 index 0000000..07bb58a --- /dev/null +++ b/console/internal/api/stats.go @@ -0,0 +1,25 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// handleStatsDashboard GET /console/api/stats/dashboard-url +// Возвращает публичный URL дашборда Grafana для текущего namespace пользователя. +// Если аналитика не настроена — возвращает {"url":""}. +func (s *Server) handleStatsDashboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + ns := s.userNS(r) + url := s.stats.DashboardURL(r.Context(), ns) + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "url": url, + "namespace": ns, + }) +} diff --git a/console/internal/stats/factory.go b/console/internal/stats/factory.go new file mode 100644 index 0000000..58b6bfa --- /dev/null +++ b/console/internal/stats/factory.go @@ -0,0 +1,36 @@ +package stats + +import ( + "log" + "os" + "strings" +) + +// NewProvider создаёт StatsProvider из переменных окружения. +// +// Переменные: +// - GRAFANA_INTERNAL_URL — внутренний URL (http://grafana.grafana.svc.cluster.local:3000) +// - GRAFANA_PUBLIC_URL — публичный URL для ссылок (https://fission.kube5s.ru/grafana) +// - GRAFANA_ADMIN_USER — имя admin (default: "admin") +// - GRAFANA_ADMIN_PASS — пароль admin +func NewProvider() StatsProvider { + internalURL := strings.TrimSpace(os.Getenv("GRAFANA_INTERNAL_URL")) + if internalURL == "" { + log.Printf("stats: GRAFANA_INTERNAL_URL not set — using NoopProvider") + return NoopProvider{} + } + + publicURL := strings.TrimSpace(os.Getenv("GRAFANA_PUBLIC_URL")) + if publicURL == "" { + publicURL = "https://fission.kube5s.ru/grafana" + } + + adminUser := strings.TrimSpace(os.Getenv("GRAFANA_ADMIN_USER")) + if adminUser == "" { + adminUser = "admin" + } + adminPass := os.Getenv("GRAFANA_ADMIN_PASS") + + log.Printf("stats: GrafanaProvider internalURL=%s publicURL=%s user=%s", internalURL, publicURL, adminUser) + return NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass) +} diff --git a/console/internal/stats/grafana.go b/console/internal/stats/grafana.go new file mode 100644 index 0000000..e89c5db --- /dev/null +++ b/console/internal/stats/grafana.go @@ -0,0 +1,382 @@ +package stats + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +// GrafanaProvider реализует StatsProvider через Grafana HTTP API. +// +// Для каждого namespace создаётся изолированная Grafana Organization: +// - PostgreSQL datasource (тот же DSN, uid="fission-user-pg") +// - Dashboard с hardcoded WHERE namespace='...' +// - Public Dashboard (без логина) → accessToken +// +// Потокобезопасен: sync.RWMutex + per-namespace singleflight. +type GrafanaProvider struct { + internalURL string // http://grafana.grafana.svc.cluster.local:3000 + publicURL string // https://fission.kube5s.ru/grafana + adminUser string + adminPass string + http *http.Client + + mu sync.RWMutex + tokens map[string]string // namespace → publicDashboardAccessToken + orgIDs map[string]int64 // namespace → grafana orgId +} + +// NewGrafanaProvider создаёт GrafanaProvider. +func NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass string) *GrafanaProvider { + return &GrafanaProvider{ + internalURL: strings.TrimRight(internalURL, "/"), + publicURL: strings.TrimRight(publicURL, "/"), + adminUser: adminUser, + adminPass: adminPass, + http: &http.Client{Timeout: 20 * time.Second}, + tokens: make(map[string]string), + orgIDs: make(map[string]int64), + } +} + +// EnsureOrgForNamespace идемпотентно создаёт Grafana Org + datasource + dashboard + public link. +func (g *GrafanaProvider) EnsureOrgForNamespace(ctx context.Context, namespace, email string) error { + // Быстрый путь: уже провизировано в этом процессе + g.mu.RLock() + _, cached := g.tokens[namespace] + g.mu.RUnlock() + if cached { + return nil + } + + // Шаг 1: получить или создать Org + orgID, err := g.getOrCreateOrg(ctx, namespace) + if err != nil { + return fmt.Errorf("getOrCreateOrg(%s): %w", namespace, err) + } + + // Шаг 2: создать datasource в этой Org (идемпотентно) + if err := g.ensureDatasource(ctx, orgID); err != nil { + log.Printf("stats: ensureDatasource org=%d ns=%s: %v", orgID, namespace, err) + // не фатально — dashboard может не работать но org создана + } + + // Шаг 3: создать dashboard с hardcoded namespace (идемпотентно) + dashUID, err := g.ensureDashboard(ctx, orgID, namespace) + if err != nil { + return fmt.Errorf("ensureDashboard org=%d ns=%s: %w", orgID, namespace, err) + } + + // Шаг 4: получить или создать public dashboard → accessToken + token, err := g.ensurePublicDashboard(ctx, orgID, dashUID) + if err != nil { + return fmt.Errorf("ensurePublicDashboard org=%d dash=%s: %w", orgID, dashUID, err) + } + + // Кэшируем + g.mu.Lock() + g.tokens[namespace] = token + g.orgIDs[namespace] = orgID + g.mu.Unlock() + + log.Printf("stats: org provisioned ns=%s orgId=%d publicToken=%s...", namespace, orgID, token[:8]) + return nil +} + +// DashboardURL возвращает публичный URL или "" если ещё не провизировано. +func (g *GrafanaProvider) DashboardURL(ctx context.Context, namespace string) string { + // Сначала пробуем из кэша + g.mu.RLock() + token, ok := g.tokens[namespace] + g.mu.RUnlock() + if ok && token != "" { + return g.publicURL + "/public-dashboards/" + token + } + + // Кэш промах (после перезапуска сервера) — провизируем заново + if err := g.EnsureOrgForNamespace(ctx, namespace, ""); err != nil { + log.Printf("stats: DashboardURL re-provision ns=%s: %v", namespace, err) + return "" + } + + g.mu.RLock() + token = g.tokens[namespace] + g.mu.RUnlock() + if token == "" { + return "" + } + return g.publicURL + "/public-dashboards/" + token +} + +// --- Grafana API helpers --- + +// getOrCreateOrg возвращает orgId существующей или создаёт новую Org. +func (g *GrafanaProvider) getOrCreateOrg(ctx context.Context, namespace string) (int64, error) { + // Проверяем кэш orgIDs + g.mu.RLock() + if id, ok := g.orgIDs[namespace]; ok { + g.mu.RUnlock() + return id, nil + } + g.mu.RUnlock() + + // GET /api/orgs/name/{namespace} + resp, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/orgs/name/"+namespace, 0, nil) + if err != nil { + return 0, err + } + if resp.StatusCode == http.StatusOK { + var org struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(body, &org); err != nil { + return 0, fmt.Errorf("parse org: %w", err) + } + return org.ID, nil + } + + // Org не найдена — создаём + payload := map[string]string{"name": namespace} + resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/orgs", 0, payload) + if err != nil { + return 0, err + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return 0, fmt.Errorf("create org status=%d body=%s", resp.StatusCode, string(body)) + } + var created struct { + OrgID int64 `json:"orgId"` + } + if err := json.Unmarshal(body, &created); err != nil { + return 0, fmt.Errorf("parse create org: %w", err) + } + return created.OrgID, nil +} + +// ensureDatasource создаёт PostgreSQL datasource в org (uid="fission-user-pg"). +// Идемпотентен: 409 Conflict считается успехом. +func (g *GrafanaProvider) ensureDatasource(ctx context.Context, orgID int64) error { + // Проверяем есть ли уже datasource в этой org + resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-user-pg", orgID, nil) + if err != nil { + return err + } + if resp.StatusCode == http.StatusOK { + return nil // уже есть + } + + // Получаем DSN из уже существующего datasource в Org 1 (uid=fission-pg) + _, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-pg", 1, nil) + if err != nil { + return fmt.Errorf("get main datasource: %w", err) + } + var ds struct { + URL string `json:"url"` + JSONData json.RawMessage `json:"jsonData"` + SecureJSONData struct { + Password string `json:"password"` + } `json:"secureJsonData"` + } + if err := json.Unmarshal(body, &ds); err != nil { + return fmt.Errorf("parse main datasource: %w", err) + } + + // Создаём копию datasource в новой Org + payload := map[string]any{ + "name": "fission-pg", + "type": "postgres", + "uid": "fission-user-pg", + "url": ds.URL, + "access": "proxy", + "jsonData": map[string]any{ + "sslmode": "disable", + "postgresVersion": 1700, + "timescaledb": false, + }, + "secureJsonData": ds.SecureJSONData, + } + resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/datasources", orgID, payload) + if err != nil { + return err + } + if resp.StatusCode == http.StatusConflict { + return nil // уже существует + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("create datasource status=%d body=%s", resp.StatusCode, string(body)) + } + return nil +} + +// ensureDashboard создаёт/обновляет user-дашборд в org. +// Возвращает uid дашборда. +func (g *GrafanaProvider) ensureDashboard(ctx context.Context, orgID int64, namespace string) (string, error) { + const dashUID = "fission-user-overview" + + // Проверяем существование + resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/dashboards/uid/"+dashUID, orgID, nil) + if err != nil { + return "", err + } + if resp.StatusCode == http.StatusOK { + return dashUID, nil // уже есть + } + + // Импортируем dashboard JSON с hardcoded namespace + dashJSON := userDashboardJSON(namespace) + payload := map[string]any{ + "dashboard": json.RawMessage(dashJSON), + "overwrite": true, + "folderId": 0, + } + resp, body, err := g.grafanaRequest(ctx, http.MethodPost, "/api/dashboards/db", orgID, payload) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("import dashboard status=%d body=%s", resp.StatusCode, string(body)) + } + return dashUID, nil +} + +// ensurePublicDashboard создаёт public dashboard и возвращает accessToken. +// Идемпотентен: если уже существует — возвращает существующий token. +func (g *GrafanaProvider) ensurePublicDashboard(ctx context.Context, orgID int64, dashUID string) (string, error) { + path := "/api/dashboards/uid/" + dashUID + "/public-dashboards" + + // Проверяем существование + resp, body, err := g.grafanaRequest(ctx, http.MethodGet, path, orgID, nil) + if err != nil { + return "", err + } + if resp.StatusCode == http.StatusOK { + var pd struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(body, &pd); err != nil { + return "", fmt.Errorf("parse public dashboard: %w", err) + } + if pd.AccessToken != "" { + return pd.AccessToken, nil + } + } + + // Создаём + payload := map[string]any{ + "isEnabled": true, + "annotationsEnabled": false, + "timeSelectionEnabled": true, + } + resp, body, err = g.grafanaRequest(ctx, http.MethodPost, path, orgID, payload) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("create public dashboard status=%d body=%s", resp.StatusCode, string(body)) + } + + var pd struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(body, &pd); err != nil { + return "", fmt.Errorf("parse created public dashboard: %w", err) + } + if pd.AccessToken == "" { + return "", fmt.Errorf("empty accessToken in response: %s", string(body)) + } + return pd.AccessToken, nil +} + +// grafanaRequest выполняет HTTP запрос к Grafana API. +// orgID > 0 → устанавливает X-Grafana-Org-Id заголовок (thread-safe, без смены контекста). +// orgID == 0 → без заголовка (используется Org 1 admin по умолчанию). +func (g *GrafanaProvider) grafanaRequest(ctx context.Context, method, path string, orgID int64, payload any) (*http.Response, []byte, error) { + var bodyReader io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return nil, nil, fmt.Errorf("marshal payload: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, g.internalURL+path, bodyReader) + if err != nil { + return nil, nil, fmt.Errorf("new request: %w", err) + } + req.SetBasicAuth(g.adminUser, g.adminPass) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if orgID > 0 { + req.Header.Set("X-Grafana-Org-Id", fmt.Sprintf("%d", orgID)) + } + + resp, err := g.http.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("do request %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, fmt.Errorf("read body: %w", err) + } + return resp, body, nil +} + +// userDashboardJSON генерирует JSON дашборда для конкретного namespace. +// Namespace вшит прямо в SQL запросы — без template variables. +// Dashboard uid="fission-user-overview" (per-org, без конфликтов между org). +func userDashboardJSON(namespace string) string { + // Безопасное экранирование namespace для SQL (namespace это sha256 hex — только [a-z0-9-]) + ns := strings.ReplaceAll(namespace, "'", "''") + return fmt.Sprintf(`{ + "title": "Мои функции — %s", + "uid": "fission-user-overview", + "tags": ["fission", "user"], + "timezone": "browser", + "refresh": "1m", + "time": {"from": "now-24h", "to": "now"}, + "panels": [ + { + "id": 1, "title": "Вызовы в час", "type": "timeseries", + "gridPos": {"x": 0, "y": 0, "w": 16, "h": 8}, + "datasource": {"type": "postgres", "uid": "fission-user-pg"}, + "targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, function_name FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1, function_name ORDER BY 1", "format": "time_series", "refId": "A"}] + }, + { + "id": 2, "title": "Успех vs Ошибки", "type": "piechart", + "gridPos": {"x": 16, "y": 0, "w": 8, "h": 8}, + "datasource": {"type": "postgres", "uid": "fission-user-pg"}, + "targets": [{"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' WHEN status_code = 0 THEN 'event' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1", "format": "table", "refId": "A"}] + }, + { + "id": 3, "title": "Топ функций", "type": "bargauge", + "gridPos": {"x": 0, "y": 8, "w": 12, "h": 8}, + "datasource": {"type": "postgres", "uid": "fission-user-pg"}, + "targets": [{"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1 ORDER BY 2 DESC LIMIT 10", "format": "table", "refId": "A"}] + }, + { + "id": 4, "title": "Средняя латентность (ms)", "type": "timeseries", + "gridPos": {"x": 12, "y": 8, "w": 12, "h": 8}, + "datasource": {"type": "postgres", "uid": "fission-user-pg"}, + "targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() AND trigger_type != 'event' GROUP BY 1 ORDER BY 1", "format": "time_series", "refId": "A"}] + }, + { + "id": 5, "title": "Последние события", "type": "table", + "gridPos": {"x": 0, "y": 16, "w": 24, "h": 8}, + "datasource": {"type": "postgres", "uid": "fission-user-pg"}, + "targets": [{"rawSql": "SELECT started_at AS time, function_name, trigger_type, event_type, status_code, duration_ms, error_msg FROM invocations WHERE namespace = '%s' ORDER BY started_at DESC LIMIT 50", "format": "table", "refId": "A"}] + } + ], + "schemaVersion": 39 +}`, namespace, ns, ns, ns, ns, ns) +} diff --git a/console/internal/stats/noop.go b/console/internal/stats/noop.go new file mode 100644 index 0000000..f66fca2 --- /dev/null +++ b/console/internal/stats/noop.go @@ -0,0 +1,10 @@ +package stats + +import "context" + +// NoopProvider — заглушка когда GRAFANA_INTERNAL_URL не задан. +// Ни на что не влияет, не крашит. +type NoopProvider struct{} + +func (NoopProvider) EnsureOrgForNamespace(_ context.Context, _, _ string) error { return nil } +func (NoopProvider) DashboardURL(_ context.Context, _ string) string { return "" } diff --git a/console/internal/stats/provider.go b/console/internal/stats/provider.go new file mode 100644 index 0000000..c80dc76 --- /dev/null +++ b/console/internal/stats/provider.go @@ -0,0 +1,20 @@ +// Package stats — аналитический слой консоли. +// +// StatsProvider абстрагирует конкретный инструмент (Grafana, Prometheus, etc.). +// При смене инструмента достаточно заменить реализацию и переменные окружения. +package stats + +import "context" + +// StatsProvider — интерфейс аналитики. +// Реализации: GrafanaProvider (GRAFANA_INTERNAL_URL задан), NoopProvider (заглушка). +type StatsProvider interface { + // EnsureOrgForNamespace идемпотентно создаёт аналитическое пространство + // для namespace (Grafana Org + datasource + dashboard + public link). + // Вызывается при handleAuth — fire-and-forget горутиной. + EnsureOrgForNamespace(ctx context.Context, namespace, email string) error + + // DashboardURL возвращает публичный URL дашборда без логина. + // Возвращает "" если аналитика не настроена или provisioning не завершён. + DashboardURL(ctx context.Context, namespace string) string +} diff --git a/console/ui/index.html b/console/ui/index.html index b0972c0..e98a19a 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -102,13 +102,14 @@
NUBES
FISSION CONSOLE
-
v1.3.86
+
v1.3.87
+
@@ -542,7 +543,7 @@
- v1.3.86 + v1.3.87
diff --git a/console/ui/js/auth.js b/console/ui/js/auth.js index 9b8fdb7..2dcb33a 100644 --- a/console/ui/js/auth.js +++ b/console/ui/js/auth.js @@ -93,6 +93,27 @@ function doLogout() { showLoginOverlay(); } +async function openAnalytics() { + var token = localStorage.getItem('auth_token'); + var env = localStorage.getItem('auth_env') || 'test'; + if (!token) { alert('Требуется авторизация'); return; } + try { + var r = await fetch(API_BASE + '/stats/dashboard-url', { + headers: { 'X-Auth-Token': token, 'X-Auth-Env': env } + }); + if (!r.ok) throw new Error('HTTP ' + r.status); + var d = await r.json(); + if (d.url) { + window.open(d.url, '_blank', 'noopener'); + } else { + // Grafana не настроена или org ещё провизируется — ссылка на оператора + window.open('/grafana/', '_blank', 'noopener'); + } + } catch (e) { + alert('Аналитика временно недоступна: ' + e.message); + } +} + function checkAuth() { var storedToken = localStorage.getItem('auth_token'); if (!storedToken) {