feat: v1.3.87 — Grafana Organizations per namespace (StatsProvider + public dashboards + UI button)

This commit is contained in:
“Naeel”
2026-05-10 18:47:21 +04:00
parent 4b8c776357
commit c9f97e2f4c
11 changed files with 524 additions and 4 deletions
+3
View File
@@ -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 истёкших функций
+9 -1
View File
@@ -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
+7
View File
@@ -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})
}
+8 -1
View File
@@ -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))
+25
View File
@@ -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,
})
}
+36
View File
@@ -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)
}
+382
View File
@@ -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)
}
+10
View File
@@ -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 "" }
+20
View File
@@ -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
}
+3 -2
View File
@@ -102,13 +102,14 @@
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.86</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.87</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
<button class="btn ghost" onclick="openHelp()">Help</button>
<button class="btn ghost" onclick="openAnalytics()" title="Открыть дашборд Grafana">📊 Аналитика</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div>
</div>
@@ -542,7 +543,7 @@
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.86</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.87</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+21
View File
@@ -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) {