diff --git a/admin-console/internal/api/server.go b/admin-console/internal/api/server.go index 00f3b0c..e5c3041 100644 --- a/admin-console/internal/api/server.go +++ b/admin-console/internal/api/server.go @@ -1,9 +1,11 @@ package api import ( + "context" "encoding/json" "net/http" "strings" + "time" "admin-console/internal/metrics" "admin-console/internal/model" @@ -31,6 +33,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { // API mux.HandleFunc("/admin/api/health", s.handleHealth) mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces)) + mux.HandleFunc("/admin/api/namespaces/", s.authMiddleware(s.handleNamespaceDetail)) mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage)) mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers)) } @@ -51,7 +54,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } -// handleNamespaces возвращает список клиентских namespace-ов с их метриками. +// handleNamespaces возвращает список клиентских namespace-ов (быстро, без счётчиков). func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) { if s.k8s == nil { writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable")) @@ -65,13 +68,37 @@ func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns}) } +// handleNamespaceDetail возвращает детальную информацию об одном namespace (с CRD-счётчиками). +func (s *Server) handleNamespaceDetail(w http.ResponseWriter, r *http.Request) { + if s.k8s == nil { + writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable")) + return + } + // URL: /admin/api/namespaces/{name} + name := strings.TrimPrefix(r.URL.Path, "/admin/api/namespaces/") + if name == "" { + writeJSON(w, http.StatusBadRequest, model.ErrResp("missing namespace name")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + info, err := s.k8s.GetNamespaceDetail(ctx, name) + if err != nil { + writeJSON(w, http.StatusNotFound, model.ErrResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, info) +} + // handleUsage возвращает агрегированные метрики использования для бухгалтерии. func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) { if s.k8s == nil { writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable")) return } - usage, err := s.k8s.CollectUsage(r.Context()) + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + usage, err := s.k8s.CollectUsage(ctx) if err != nil { writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error())) return @@ -85,7 +112,9 @@ func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable")) return } - users, err := s.k8s.ListUsers(r.Context()) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + users, err := s.k8s.ListUsers(ctx) if err != nil { writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error())) return diff --git a/admin-console/internal/metrics/k8s.go b/admin-console/internal/metrics/k8s.go index e5b3559..2cfdca9 100644 --- a/admin-console/internal/metrics/k8s.go +++ b/admin-console/internal/metrics/k8s.go @@ -96,8 +96,7 @@ func (k *K8sClient) initS3() { log.Printf("[admin-console] S3 client ready, bucket=%s", bucket) } -// ListClientNamespaces возвращает namespace-ы с лейблом managed-by=fission-console. -// Для каждого считает количество объектов Fission CRD через Dynamic клиент. +// ListClientNamespaces возвращает список namespace-ов БЕЗ CRD-счётчиков (быстро). func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) { nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{ LabelSelector: "managed-by=fission-console", @@ -108,17 +107,30 @@ func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.Namespace result := make([]model.NamespaceInfo, 0, len(nsList.Items)) for _, ns := range nsList.Items { - info := model.NamespaceInfo{ + result = append(result, model.NamespaceInfo{ Name: ns.Name, UserEmail: ns.Labels["user"], CreatedAt: ns.CreationTimestamp.Format(time.RFC3339), - } - k.fillCRDCounts(ctx, ns.Name, &info) - result = append(result, info) + }) } return result, nil } +// GetNamespaceDetail возвращает информацию об одном namespace с CRD-счётчиками. +func (k *K8sClient) GetNamespaceDetail(ctx context.Context, nsName string) (*model.NamespaceInfo, error) { + ns, err := k.cs.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + info := &model.NamespaceInfo{ + Name: ns.Name, + UserEmail: ns.Labels["user"], + CreatedAt: ns.CreationTimestamp.Format(time.RFC3339), + } + k.fillCRDCounts(ctx, nsName, info) + return info, nil +} + // fillCRDCounts заполняет счётчики Fission-ресурсов для одного namespace. func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) { count := func(gvr schema.GroupVersionResource) int { @@ -126,8 +138,6 @@ func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.Na if err != nil { return 0 } - // List возвращает items и list-metadata с remaining count. - // Используем оставшийся счёт для точности. if rem := list.GetRemainingItemCount(); rem != nil { return int(*rem) + len(list.Items) } @@ -222,10 +232,8 @@ func (k *K8sClient) ListUsers(ctx context.Context) ([]model.UserInfo, error) { var users []model.UserInfo for _, ns := range nsList.Items { - // label user содержит email владельца namespace email := ns.Labels["user"] if email == "" { - // fallback: попробовать прочитать из secret secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{}) if err == nil { for key := range secret.Data { diff --git a/admin-console/ui/index.html b/admin-console/ui/index.html index dcd8d2b..6b286ae 100644 --- a/admin-console/ui/index.html +++ b/admin-console/ui/index.html @@ -38,6 +38,9 @@ .pill-blue { background: #1e3a5f; color: #60a5fa; } .err { color: #f87171; font-size: 13px; padding: 16px 0; } .loader { color: #6b7280; font-size: 13px; padding: 16px 0; } + .cnt { color: #6b7280; font-size: 12px; } + .cnt.loading { animation: pulse 1.5s infinite; } + @keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} } @@ -67,19 +70,28 @@