diff --git a/CHANGELOG.md b/CHANGELOG.md index 1977e67..ea95e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning: [Se ## [Unreleased] +### Added + +- **Dashboard:** the `/dashboard` page now surfaces Kubernetes host telemetry (node count, ready nodes, allocatable CPU and memory, distinct kubelet versions and OS image) sourced from `core/v1/nodes` via the `KubeVirtDriver`. The new `GET /api/v1/dashboard/summary` field `hosts` is additive; older clients keep working. The section renders a disabled state when the cluster is unreachable (Forbidden, RBAC, no kubeconfig) so the endpoint never 500s. Tier 1 only — no new dependency. + +### Changed + +- **UI:** the dashboard host section is now titled "Cluster nodes" (was "Cluster host"), and the OS label is "OS image" / "Imagem do SO" (was "OS" / "SO"). The data is `Node.Status.NodeInfo.OSImage` from `core/v1/nodes` — i.e. the OS of the cluster nodes that run the workloads, **not** the OS of the developer's machine. In `kind` the value is the kind node image (Debian trixie); in kubeadm on bare metal it is the host OS (e.g. AlmaLinux); in AKS/EKS it is the managed node image (Ubuntu / Amazon Linux). No backend changes, no contract change. +- **UI:** dashboard capacity labels now read "Allocatable CPU (cluster)" / "Allocatable memory (cluster)" (PT: "CPU alocável (cluster)" / "Memória alocável (cluster)"). The values are the **sum of `Status.Allocatable` across all nodes** — they grow with the node count, not with a single host. + +### Changed + +- **UI:** the "(cluster)" suffix on the CPU and Memory capacity labels was making the cards overflow and break into a second line when the sidebar is expanded. Reverted the labels to "Allocatable CPU" / "Allocatable memory" and moved the aggregation hint to a `title` tooltip on the card. The section title "Cluster nodes" already conveys the scope. +- **UI:** the "Ready nodes" card now shows a small status badge in the top-right corner that summarises cluster node health at a glance: green "All ready" when every node is Ready, yellow "N degraded" when some are not, red "None ready" when zero are. The badge reuses the same colour tokens as the dashboard "Health" card. No new card, no new data — it just lifts `nodes` / `nodes_ready` from the existing payload. + +### Added + +- **Dashboard:** the stats row now includes a **Storage** card that aggregates tenant-scoped PVCs: `bound / total` (auto-scaled to B/KiB/MiB/GiB/TiB), the percentage of allocated capacity, and the PVC count. New `GET /api/v1/dashboard/summary` field `storage` (additive). Both figures are `spec.resources.requests.storage` sums — allocated capacity, never real guest consumption. Backend reuses the existing PVC list path in `KubeVirtDriver`; empty store, memory mode, and Forbidden degrade gracefully. + +### Added + +- **Dashboard:** the Cluster nodes section now includes a **Cluster addons** strip with one coloured dot per critical dependency: KubeVirt, CDI, Multus, metrics-server, networking. Statuses: green (installed and reachable), grey (absent — CRD/API not registered), yellow (unreachable — Forbidden or transient failure). New `GET /api/v1/dashboard/summary` field `addons` (additive) with a 30s in-memory cache. The probe lists CRDs metadata-only (`PartialObjectMetadataList` Accept header) with a 4s call timeout, and derives the API group from the CRD object name — the full OpenAPI schemas never cross the wire. Discovery calls are not AST-scanned by `rbac_contract_test.go`; the required `apiextensions.k8s.io/customresourcedefinitions` get/list grant is listed under `dynamicClient` in `docs/rbac-contract.yaml` for the chart. Memory mode and nil client render every dot as yellow. + +### Changed + +- **API:** the addons probe is now **dynamic** — `KubeVirtDriver.probeAddons` lists every `CustomResourceDefinition` and groups by the `app.kubernetes.io/name` label (the standard Helm/Kustomize convention). The hard-coded list (kubevirt, cdi, multus, metrics-server, networking, cert-manager) is gone; any chart that follows the convention appears automatically. Because the list is fetched metadata-only, the `Established` condition is no longer observable — status is `ok` by presence. CRDs without the label are ignored. The UI keeps the i18n map for the well-known names and falls back to the raw `app.kubernetes.io/name` value for everything else. + +### Changed + +- **API:** `probeAddons` now resolves the addon name through three Helm labels (`app.kubernetes.io/name`, then `part-of`, then `component`) and falls back to a substring match against a small alias table (`kubevirt.io` → `kubevirt`, `cdi.kubevirt.io` → `cdi`, `k8s.cni.cncf.io` → `multus`, `cert-manager.io` → `cert-manager`, `istio.io` → `istio`, `monitoring.coreos.com` → `prometheus`, `tekton.dev` → `tekton`, `argoproj.io` → `argocd`). Add-ons installed without Helm labels still surface, and a CRD whose group has no alias is silently skipped (no false positives). + +### Changed + +- **Security:** the dashboard **Cluster overview** section (nodes, ready, CPU/memory capacity and usage, addons strip) is now visible to **root only**. The handler passes `actor.Role` to the service, the service omits the `hosts`, `storage` and `addons` fields from `DashboardSummary` for any non-root actor, and the React side hides the card when `selectIsRoot` is false. Tenant admins and tenant users keep the rest of the dashboard (volumes, VPCs, SGs, networks, recent VMs, health badge) but the platform-wide telemetry is gone — both from the rendered page and from the JSON response. + +### Added (backend — UI integration pending) + +- **API:** `GET /api/v1/dashboard/summary` now includes a new optional `hosts.usage` field with cluster-wide `cpu_usage_millicores` and `memory_usage_bytes` aggregated from `metrics.k8s.io/v1beta1/nodes` (served by `metrics-server`). Backend reads via the discovery REST client, caches the result for 30s, and tolerates the API being absent (404), Forbidden (403), or transiently unavailable (5xx, timeout) — the field is omitted and the dashboard renders a degraded state. The chart still has to grant `metrics.k8s.io/nodes` get/list in the API ClusterRole (entry listed under `dynamicClient` in `docs/rbac-contract.yaml`); `metrics-server` is a documented prerequisite. The UI is unchanged in this slice; Tier 1 cards keep their current look. + +### Added (UI — Tier 2 cards) + +- **UI:** the Cluster nodes section now renders usage bars in the CPU and Memory cards whenever the backend reports `hosts.usage`. Each bar shows `used / allocatable` and a percentage with a colour ramp: `< 60%` primary, `60–85%` warning, `≥ 85%` error. When `metrics-server` is absent the cards show "metrics-server unavailable" / "metrics-server indisponível" instead of the bar — the endpoint still answers, the dashboard stays usable. PT/EN i18n parity checked by `i18n.test.ts`. + ## [0.11.3] - 2026-10-07 ### Fixed diff --git a/docs/rbac-contract.yaml b/docs/rbac-contract.yaml index 56fa79a..2805c5d 100644 --- a/docs/rbac-contract.yaml +++ b/docs/rbac-contract.yaml @@ -11,7 +11,9 @@ # fails at deploy time (core#196 to core#198 came from exactly that). # # Not covered: dynamic-client access (virtfoundry.io CRDs, KubeVirt, CDI, Multus), which is -# granted per resource in the chart, and calls made through a stored client variable. +# granted per resource in the chart, and calls made through a stored client variable. These +# accesses are listed under `dynamicClient` below for the chart to grant; rbac_contract_test.go +# does not enforce them (it only AST-scans typed client-go calls). # # scope: cluster the grant must be in the ClusterRole (tenant namespaces are created at # runtime, so RBAC cannot name them). This is the default. @@ -57,3 +59,29 @@ rules: - apiGroup: networking.k8s.io resource: networkpolicies verbs: [get, create, update, delete] +# dynamicClient documents access through the discovery REST client +# (cs.Discovery().RESTClient() / dynamic client) — a pattern used when +# the typed client-go does not expose the group (e.g. metrics.k8s.io +# comes from metrics-server, not from client-go). rbac_contract_test.go +# does not scan these calls, so the chart is the source of truth and +# must grant them. Each entry lists (apiGroup, resource, verbs). +dynamicClient: + - apiGroup: metrics.k8s.io + resource: nodes + verbs: [get, list] + note: >- + Tier 2 of the dashboard host metrics spec. Source: KubeVirtDriver.fetchClusterUsage + (internal/infra/hypervisor/kubevirt.go). Reachable only when metrics-server is + installed (documented prerequisite in helm-charts). The dashboard degrades gracefully + when this API is absent, Forbidden, or transiently unavailable. + - apiGroup: apiextensions.k8s.io + resource: customresourcedefinitions + verbs: [get, list] + note: >- + Dashboard cluster-addons strip. Source: KubeVirtDriver.probeAddons + (internal/infra/hypervisor/kubevirt.go). The list is requested as a + PartialObjectMetadataList (Accept: application/json;as=PartialObjectMetadataList; + g=meta.k8s.io;v=v1), so only CRD names and labels cross the wire — never the + OpenAPI schemas. Without this grant the strip renders empty; the dashboard + degrades gracefully on Forbidden. + diff --git a/internal/api/handler/platform_handler.go b/internal/api/handler/platform_handler.go index b5bb858..366753a 100644 --- a/internal/api/handler/platform_handler.go +++ b/internal/api/handler/platform_handler.go @@ -1073,10 +1073,12 @@ func (h *PlatformHandler) DashboardSummary(w http.ResponseWriter, r *http.Reques return } perms := []string{auth.PermAll} + var role platform.Role if actor := middleware.GetActor(r.Context()); actor != nil { perms = actor.Permissions + role = actor.Role } - summary, err := h.svc.DashboardSummary(r.Context(), tid, perms) + summary, err := h.svc.DashboardSummary(r.Context(), tid, perms, role) if err != nil { respondError(w, err) return diff --git a/internal/infra/hypervisor/addons_health_test.go b/internal/infra/hypervisor/addons_health_test.go new file mode 100644 index 0000000..7ca9876 --- /dev/null +++ b/internal/infra/hypervisor/addons_health_test.go @@ -0,0 +1,224 @@ +package hypervisor + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func newCRDTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, kubernetes.Interface) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + return srv, cs +} + +// crdWithLabel mirrors a PartialObjectMetadata item: metadata only, no +// spec/status — the probe requests the list with the meta.k8s.io transform. +func crdWithLabel(name, labelValue string) string { + return `{ + "metadata": { + "name": "` + name + `", + "labels": {"app.kubernetes.io/name": "` + labelValue + `"} + } + }` +} + +func crdBare(name string) string { + return `{"metadata": {"name": "` + name + `"}}` +} + +func TestAddonsHealthDiscoversFromCRDs(t *testing.T) { + t.Parallel() + srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apis/apiextensions.k8s.io/v1/customresourcedefinitions": + if got := r.Header.Get("Accept"); !strings.Contains(got, "as=PartialObjectMetadataList") { + t.Errorf("Accept=%q must request PartialObjectMetadataList (metadata only, no schemas)", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [ + ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt") + `, + ` + crdWithLabel("datavolumes.cdi.kubevirt.io", "cdi") + `, + ` + crdWithLabel("networkattachmentdefinitions.k8s.cni.cncf.io", "multus") + `, + ` + crdWithLabel("certificates.cert-manager.io", "cert-manager") + `, + ` + crdWithLabel("foos.example.com", "some-addon") + `, + ` + crdBare("istiooperators.install.istio.io") + `, + ` + crdBare("networkpolicies.networking.k8s.io") + `, + ` + crdBare("widgets.unknown.example.com") + ` + ]}`)) + default: + http.NotFound(w, r) + } + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + got, err := d.AddonsHealth(context.Background()) + if err != nil { + t.Fatalf("AddonsHealth: %v", err) + } + byName := map[string]string{} + for _, a := range got.Addons { + byName[a.Name] = a.Status + } + want := map[string]string{ + "kubevirt": "ok", + "cdi": "ok", + "multus": "ok", + "cert-manager": "ok", + "some-addon": "ok", + "istio": "ok", + } + for name, status := range want { + if got := byName[name]; got != status { + t.Fatalf("addon %q: got %q want %q", name, got, status) + } + } + if _, present := byName["unknown"]; present { + t.Fatal("CRDs whose group has no alias must be ignored") + } + if _, present := byName["networking"]; present { + t.Fatal("kubernetes core networking group must not be mapped to an addon") + } + if got.CheckedAt.IsZero() { + t.Fatal("checked_at must be set") + } +} + +func TestAliasFromGroup(t *testing.T) { + t.Parallel() + cases := []struct { + group string + want string + }{ + {"kubevirt.io", "kubevirt"}, + {"cdi.kubevirt.io", "cdi"}, + {"k8s.cni.cncf.io", "multus"}, + {"cert-manager.io", "cert-manager"}, + {"networking.istio.io", "istio"}, + {"security.istio.io", "istio"}, + {"monitoring.coreos.com", "prometheus"}, + {"tekton.dev", "tekton"}, + {"argoproj.io", "argocd"}, + {"networking.k8s.io", ""}, + {"", ""}, + } + for _, c := range cases { + c := c + t.Run(c.group, func(t *testing.T) { + t.Parallel() + if got := aliasFromGroup(c.group); got != c.want { + t.Fatalf("aliasFromGroup(%q)=%q want %q", c.group, got, c.want) + } + }) + } +} + +func TestGroupFromCRDName(t *testing.T) { + t.Parallel() + cases := []struct { + name string + want string + }{ + {"virtualmachines.kubevirt.io", "kubevirt.io"}, + {"datavolumes.cdi.kubevirt.io", "cdi.kubevirt.io"}, + {"networkpolicies.networking.k8s.io", "networking.k8s.io"}, + {"nodot", ""}, + {"trailingdot.", ""}, + {"", ""}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := groupFromCRDName(c.name); got != c.want { + t.Fatalf("groupFromCRDName(%q)=%q want %q", c.name, got, c.want) + } + }) + } +} + +func TestResolveAddonName(t *testing.T) { + t.Parallel() + cases := []struct { + name string + labels map[string]string + group string + want string + }{ + {"name wins over group", map[string]string{"app.kubernetes.io/name": "x"}, "kubevirt.io", "x"}, + {"part-of fallback", map[string]string{"app.kubernetes.io/part-of": "y"}, "kubevirt.io", "y"}, + {"component fallback", map[string]string{"app.kubernetes.io/component": "z"}, "kubevirt.io", "z"}, + {"group alias fallback", nil, "kubevirt.io", "kubevirt"}, + {"nothing matches", nil, "random.example.com", ""}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := resolveAddonName(c.labels, c.group); got != c.want { + t.Fatalf("got %q want %q", got, c.want) + } + }) + } +} + +func TestAddonsHealthCRDListForbidden(t *testing.T) { + t.Parallel() + srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":403,"reason":"Forbidden"}`, http.StatusForbidden) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + got, err := d.AddonsHealth(context.Background()) + if err != nil { + t.Fatalf("AddonsHealth: %v", err) + } + if len(got.Addons) != 0 { + t.Fatalf("forbidden CRD list must yield empty addons, got %+v", got.Addons) + } +} + +func TestAddonsHealthDedupMultipleCRDsSameName(t *testing.T) { + t.Parallel() + srv, cs := newCRDTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apis/apiextensions.k8s.io/v1/customresourcedefinitions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [ + ` + crdWithLabel("virtualmachines.kubevirt.io", "kubevirt") + `, + ` + crdWithLabel("virtualmachineinstances.kubevirt.io", "kubevirt") + ` + ]}`)) + default: + http.NotFound(w, r) + } + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + got, err := d.AddonsHealth(context.Background()) + if err != nil { + t.Fatalf("AddonsHealth: %v", err) + } + count := 0 + for _, a := range got.Addons { + if a.Name == "kubevirt" { + count++ + } + } + if count != 1 { + t.Fatalf("multiple CRDs with same app.kubernetes.io/name must dedup, got %d entries", count) + } +} diff --git a/internal/infra/hypervisor/cluster_metrics_test.go b/internal/infra/hypervisor/cluster_metrics_test.go new file mode 100644 index 0000000..eca7bee --- /dev/null +++ b/internal/infra/hypervisor/cluster_metrics_test.go @@ -0,0 +1,119 @@ +package hypervisor + +import ( + "context" + "testing" + + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newNode(name string, ready bool, cpuCap, cpuAlloc, memCap, memAlloc string, kubelet, osImage, arch string) *k8sv1.Node { + node := &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: k8sv1.NodeStatus{ + Capacity: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse(cpuCap), + k8sv1.ResourceMemory: resource.MustParse(memCap), + }, + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse(cpuAlloc), + k8sv1.ResourceMemory: resource.MustParse(memAlloc), + }, + NodeInfo: k8sv1.NodeSystemInfo{ + KubeletVersion: kubelet, + OSImage: osImage, + Architecture: arch, + }, + }, + } + if ready { + node.Status.Conditions = []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + } + } else { + node.Status.Conditions = []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionFalse}, + } + } + return node +} + +func TestClusterMetricsAggregates(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset( + newNode("a", true, "4", "3500m", "16Gi", "14Gi", "v1.30.0", "Ubuntu 22.04", "amd64"), + newNode("b", true, "8", "7", "32Gi", "30Gi", "v1.30.0", "Ubuntu 22.04", "amd64"), + newNode("c", false, "4", "3500m", "16Gi", "14Gi", "v1.29.5", "Debian 12", "arm64"), + ) + d := &KubeVirtDriver{k8sClient: cs} + m, err := d.ClusterMetrics(context.Background()) + if err != nil { + t.Fatalf("ClusterMetrics: %v", err) + } + if m.Nodes != 3 { + t.Fatalf("nodes=%d want 3", m.Nodes) + } + if m.NodesReady != 2 { + t.Fatalf("nodes_ready=%d want 2", m.NodesReady) + } + if m.CPUCapacityMilli != 16000 { + t.Fatalf("cpu_capacity_millicores=%d want 16000", m.CPUCapacityMilli) + } + wantAlloc := int64(14000) // 3.5 + 7 + 3.5 cores, in millicores + if m.CPUAllocatableMilli != wantAlloc { + t.Fatalf("cpu_allocatable_millicores=%d want %d", m.CPUAllocatableMilli, wantAlloc) + } + if m.MemoryCapacity != 64*1024*1024*1024 { + t.Fatalf("memory_capacity=%d want 64Gi", m.MemoryCapacity) + } + if m.MemoryAllocatable != 58*1024*1024*1024 { + t.Fatalf("memory_allocatable=%d want 58Gi", m.MemoryAllocatable) + } + if len(m.KubeletVersions) != 2 { + t.Fatalf("kubelet_versions=%v want 2 distinct", m.KubeletVersions) + } + if len(m.OSArchitectures) != 2 { + t.Fatalf("os_architectures=%v want 2 distinct", m.OSArchitectures) + } + if len(m.OSImages) != 2 { + t.Fatalf("os_images=%v want 2 distinct", m.OSImages) + } + if m.CollectedAt.IsZero() { + t.Fatal("collected_at must be set") + } +} + +func TestClusterMetricsEmpty(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset() + d := &KubeVirtDriver{k8sClient: cs} + m, err := d.ClusterMetrics(context.Background()) + if err != nil { + t.Fatalf("ClusterMetrics: %v", err) + } + if m.Nodes != 0 || m.NodesReady != 0 || m.CPUAllocatableMilli != 0 { + t.Fatalf("empty cluster: %+v", m) + } + if m.KubeletVersions == nil || m.OSImages == nil || m.OSArchitectures == nil { + t.Fatal("slice fields must be non-nil (empty JSON arrays)") + } +} + +func TestIsNodeReady(t *testing.T) { + t.Parallel() + ready := newNode("a", true, "1", "1", "1Gi", "1Gi", "v1", "x", "amd64") + if !isNodeReady(ready) { + t.Fatal("expected ready") + } + notReady := newNode("b", false, "1", "1", "1Gi", "1Gi", "v1", "x", "amd64") + if isNodeReady(notReady) { + t.Fatal("expected not ready") + } + unknown := &k8sv1.Node{} + if isNodeReady(unknown) { + t.Fatal("node without conditions should not be ready") + } +} \ No newline at end of file diff --git a/internal/infra/hypervisor/cluster_usage_test.go b/internal/infra/hypervisor/cluster_usage_test.go new file mode 100644 index 0000000..91fc2d9 --- /dev/null +++ b/internal/infra/hypervisor/cluster_usage_test.go @@ -0,0 +1,242 @@ +package hypervisor + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func newMetricsServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, kubernetes.Interface) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + return srv, cs +} + +func TestClusterUsageAggregates(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apis/metrics.k8s.io/v1beta1/nodes" { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "kind": "NodeMetricsList", + "apiVersion": "metrics.k8s.io/v1beta1", + "items": [ + {"usage": {"cpu": "250m", "memory": "512Mi"}, "timestamp": "2026-10-09T11:00:00Z"}, + {"usage": {"cpu": "1.5", "memory": "4Gi"}, "timestamp": "2026-10-09T11:00:00Z"}, + {"usage": {"cpu": "0", "memory": "0"}, "timestamp": "2026-10-09T11:00:00Z"} + ] + }`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("ClusterUsage: %v", err) + } + if u == nil { + t.Fatal("expected non-nil usage") + } + if got, want := u.CPUUsageMilli, int64(250+1500+0); got != want { + t.Fatalf("cpu_usage_millicores=%d want %d", got, want) + } + memWant := int64(512*1024*1024 + 4*1024*1024*1024) + if u.MemoryUsage != memWant { + t.Fatalf("memory_usage_bytes=%d want %d", u.MemoryUsage, memWant) + } + if u.WindowSeconds != int64(clusterUsageTTL/time.Second) { + t.Fatalf("window_seconds=%d want %d", u.WindowSeconds, int64(clusterUsageTTL/time.Second)) + } + if u.CollectedAt.IsZero() { + t.Fatal("collected_at must be set") + } +} + +func TestClusterUsageMetricsServerAbsent(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":404,"reason":"NotFound","message":"the server could not find the requested resource"}`, http.StatusNotFound) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("expected graceful nil on 404, got error: %v", err) + } + if u != nil { + t.Fatalf("expected nil usage when metrics-server is absent, got %+v", u) + } +} + +func TestClusterUsageForbidden(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":403,"reason":"Forbidden"}`, http.StatusForbidden) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("expected graceful nil on 403, got error: %v", err) + } + if u != nil { + t.Fatalf("expected nil usage on Forbidden, got %+v", u) + } +} + +func TestClusterUsageTransientError(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"kind":"Status","status":"Failure","code":503,"reason":"ServiceUnavailable"}`, http.StatusServiceUnavailable) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("expected graceful nil on 503, got error: %v", err) + } + if u != nil { + t.Fatalf("expected nil usage on 503, got %+v", u) + } +} + +func TestClusterUsageSkipsMalformedQuantities(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [ + {"usage": {"cpu": "250m", "memory": "512Mi"}}, + {"usage": {"cpu": "not-a-quantity", "memory": "1Gi"}}, + {"usage": {"cpu": "100m", "memory": "bogus"}} + ]}`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("malformed quantities must be skipped, not fail the call: %v", err) + } + if u == nil { + t.Fatal("expected non-nil usage") + } + if got, want := u.CPUUsageMilli, int64(250); got != want { + t.Fatalf("cpu_usage_millicores=%d want %d (only the well-formed item)", got, want) + } + if got, want := u.MemoryUsage, int64(512*1024*1024); got != want { + t.Fatalf("memory_usage_bytes=%d want %d (only the well-formed item)", got, want) + } +} + +func TestClusterUsageParseError(t *testing.T) { + t.Parallel() + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ this is not json`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + if _, err := d.ClusterUsage(context.Background()); err == nil { + t.Fatal("expected parse error to propagate") + } +} + +func TestClusterUsageCache(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [{"usage": {"cpu": "100m", "memory": "256Mi"}}]}`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("second call: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache did not absorb the second call: requests=%d want 1", got) + } +} + +func TestClusterUsageCacheTTLExpiry(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + srv, cs := newMetricsServer(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [{"usage": {"cpu": "100m", "memory": "256Mi"}}]}`)) + }) + _ = srv + + d := &KubeVirtDriver{k8sClient: cs} + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("first call: %v", err) + } + d.usageCacheMu.Lock() + d.usageCache.at = time.Now().Add(-2 * clusterUsageTTL) + d.usageCacheMu.Unlock() + if _, err := d.ClusterUsage(context.Background()); err != nil { + t.Fatalf("third call: %v", err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("cache TTL did not expire: requests=%d want 2", got) + } +} + +func TestClusterUsageNilClient(t *testing.T) { + t.Parallel() + d := &KubeVirtDriver{} + u, err := d.ClusterUsage(context.Background()) + if err != nil { + t.Fatalf("nil client: %v", err) + } + if u != nil { + t.Fatalf("nil client must yield nil usage, got %+v", u) + } +} + +func TestIsMetricsAbsent(t *testing.T) { + t.Parallel() + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"random", errors.New("some other failure"), false}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := isMetricsAbsent(c.err); got != c.want { + t.Fatalf("isMetricsAbsent(%v)=%v want %v", c.err, got, c.want) + } + }) + } +} diff --git a/internal/infra/hypervisor/kubevirt.go b/internal/infra/hypervisor/kubevirt.go index 1f6be03..04c8889 100644 --- a/internal/infra/hypervisor/kubevirt.go +++ b/internal/infra/hypervisor/kubevirt.go @@ -4,8 +4,11 @@ import ( "context" "encoding/json" "fmt" + "log" + "sort" "strings" "sync" + "time" "github.com/virtfoundry/core/internal/platform/branding" "github.com/virtfoundry/core/internal/platform/cloudinit" @@ -25,6 +28,20 @@ const ( defaultVMImage = "quay.io/kubevirt/cirros-container-disk-demo" virtioContainerDisk = "quay.io/kubevirt/virtio-container-disk:v1.8.4" windowsMachineType = "q35" + + // clusterUsageTTL bounds how often we hit metrics.k8s.io. metrics-server itself + // scrapes the kubelet every ~15s; a 30s server-side cache matches the design + // spec (Tier 2) and keeps the dashboard poll cheap. + clusterUsageTTL = 30 * time.Second + // clusterUsageCallTimeout caps a single metrics.k8s.io round-trip. + clusterUsageCallTimeout = 4 * time.Second + // addonsProbeCallTimeout caps a single CRD list round-trip in probeAddons, + // same bound as the usage call. + addonsProbeCallTimeout = 4 * time.Second + // partialObjectMetadataListAccept asks the API server for CRD metadata only + // (no OpenAPI schemas), keeping the addons probe payload small. The plain + // JSON fallback keeps the probe working if the transform is ignored. + partialObjectMetadataListAccept = "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1,application/json" ) // Cirros only drives the default VGA device; virtio-gpu yields a black VNC screen. @@ -38,8 +55,29 @@ func videoDeviceForImage(image string) *kubevirtv1.VideoDevice { // KubeVirtDriver implements Driver using KubeVirt CRDs. type KubeVirtDriver struct { virtClient kubecli.KubevirtClient - k8sClient *kubernetes.Clientset + k8sClient kubernetes.Interface namespace string + + // usageCache memoises ClusterUsage between dashboard polls. A nil value with + // a non-nil err represents "metrics-server unreachable"; the dashboard turns + // that into a degraded state. + usageCacheMu sync.Mutex + usageCache *clusterUsageCacheEntry + + // addonsCache memoises AddonsHealth between dashboard polls. + addonsCacheMu sync.Mutex + addonsCache *addonsCacheEntry +} + +type clusterUsageCacheEntry struct { + value *ClusterUsage + err error + at time.Time +} + +type addonsCacheEntry struct { + value *AddonsHealth + at time.Time } type KubeVirtConfig struct { @@ -83,20 +121,34 @@ func NewKubeVirtDriver(config KubeVirtConfig) (*KubeVirtDriver, error) { namespace = "default" } + return newKubeVirtDriver(virtClient, k8sClient, namespace), nil +} + +func newKubeVirtDriver(virtClient kubecli.KubevirtClient, k8sClient kubernetes.Interface, namespace string) *KubeVirtDriver { return &KubeVirtDriver{ virtClient: virtClient, k8sClient: k8sClient, namespace: namespace, - }, nil + } +} + +func NewKubeVirtDriverForTest(virtClient kubecli.KubevirtClient, k8sClient kubernetes.Interface) *KubeVirtDriver { + return newKubeVirtDriver(virtClient, k8sClient, "default") } func (d *KubeVirtDriver) WithNamespace(ns string) *KubeVirtDriver { if ns == "" { return d } - copy := *d - copy.namespace = ns - return © + // Build a fresh driver instead of copying the struct: copying would clone + // the cache mutexes (go vet copylocks) while sharing the cache entries. + // Cluster telemetry is only queried through the base driver, so the copy + // starts with empty caches. + return &KubeVirtDriver{ + virtClient: d.virtClient, + k8sClient: d.k8sClient, + namespace: ns, + } } func (d *KubeVirtDriver) VirtClient() kubecli.KubevirtClient { @@ -430,6 +482,371 @@ func (d *KubeVirtDriver) GetHostResources(ctx context.Context) (map[string]inter }, nil } +func (d *KubeVirtDriver) ClusterMetrics(ctx context.Context) (*ClusterMetrics, error) { + nodes, err := d.k8sClient.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + + out := &ClusterMetrics{ + Nodes: len(nodes.Items), + KubeletVersions: []string{}, + OSImages: []string{}, + OSArchitectures: []string{}, + CollectedAt: time.Now().UTC(), + } + + kubelet := map[string]struct{}{} + osImage := map[string]struct{}{} + osArch := map[string]struct{}{} + + for i := range nodes.Items { + node := &nodes.Items[i] + + if isNodeReady(node) { + out.NodesReady++ + } + out.CPUCapacityMilli += node.Status.Capacity.Cpu().MilliValue() + out.CPUAllocatableMilli += node.Status.Allocatable.Cpu().MilliValue() + out.MemoryCapacity += node.Status.Capacity.Memory().Value() + out.MemoryAllocatable += node.Status.Allocatable.Memory().Value() + + if v := node.Status.NodeInfo.KubeletVersion; v != "" { + kubelet[v] = struct{}{} + } + if v := node.Status.NodeInfo.OSImage; v != "" { + osImage[v] = struct{}{} + } + if v := node.Status.NodeInfo.Architecture; v != "" { + osArch[v] = struct{}{} + } + } + + out.KubeletVersions = sortedKeys(kubelet) + out.OSImages = sortedKeys(osImage) + out.OSArchitectures = sortedKeys(osArch) + return out, nil +} + +// ClusterUsage aggregates metrics.k8s.io/v1beta1 NodeMetrics into a single +// cluster-wide usage figure (CPU millicores + memory bytes). It returns +// (nil, nil) when metrics-server is absent, Forbidden, or when the request +// fails with a transient server error — the dashboard must keep working +// when the cluster does not expose usage data. Genuine errors (parse failure, +// etc.) are propagated. +func (d *KubeVirtDriver) ClusterUsage(ctx context.Context) (*ClusterUsage, error) { + d.usageCacheMu.Lock() + cached := d.usageCache + if cached != nil && time.Since(cached.at) < clusterUsageTTL { + d.usageCacheMu.Unlock() + return cached.value, cached.err + } + d.usageCacheMu.Unlock() + + value, err := d.fetchClusterUsage(ctx) + + d.usageCacheMu.Lock() + d.usageCache = &clusterUsageCacheEntry{value: value, err: err, at: time.Now()} + d.usageCacheMu.Unlock() + + return value, err +} + +// fetchClusterUsage performs a single metrics.k8s.io round-trip. It is not +// covered by rbac_contract_test.go (the test only sees typed client-go +// calls; metrics.k8s.io is reached via the discovery REST client, which is +// an accepted pattern — see the dynamic-client note in docs/rbac-contract.yaml). +func (d *KubeVirtDriver) fetchClusterUsage(ctx context.Context) (*ClusterUsage, error) { + if d.k8sClient == nil { + return nil, nil + } + restClient := d.k8sClient.Discovery().RESTClient() + if restClient == nil { + return nil, nil + } + callCtx, cancel := context.WithTimeout(ctx, clusterUsageCallTimeout) + defer cancel() + + raw, err := restClient. + Get(). + AbsPath("/apis/metrics.k8s.io/v1beta1/nodes"). + Do(callCtx). + Raw() + if err != nil { + if isMetricsAbsent(err) { + log.Printf("[cluster-usage] metrics.k8s.io unavailable: %v", err) + return nil, nil + } + return nil, fmt.Errorf("get metrics.k8s.io nodes: %w", err) + } + + var list struct { + Items []struct { + Usage struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + } `json:"usage"` + Timestamp string `json:"timestamp"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &list); err != nil { + return nil, fmt.Errorf("decode metrics.k8s.io nodes: %w", err) + } + + out := &ClusterUsage{ + WindowSeconds: int64(clusterUsageTTL / time.Second), + CollectedAt: time.Now().UTC(), + } + for _, item := range list.Items { + // A malformed quantity from metrics.k8s.io must not panic the process + // (resource.MustParse) nor fail the whole call — skip the node instead. + cpu, err := resource.ParseQuantity(item.Usage.CPU) + if err != nil { + log.Printf("[cluster-usage] skipping node metrics: invalid cpu quantity %q: %v", item.Usage.CPU, err) + continue + } + mem, err := resource.ParseQuantity(item.Usage.Memory) + if err != nil { + log.Printf("[cluster-usage] skipping node metrics: invalid memory quantity %q: %v", item.Usage.Memory, err) + continue + } + out.CPUUsageMilli += cpu.MilliValue() + out.MemoryUsage += mem.Value() + } + return out, nil +} + +// isMetricsAbsent recognises the failure modes the dashboard tolerates: +// - the API is not registered (404, metrics-server not installed) +// - the ServiceAccount is not granted access (403) +// - the API server is overloaded or briefly unavailable (5xx, timeout) +func isMetricsAbsent(err error) bool { + if err == nil { + return false + } + if errors.IsNotFound(err) || errors.IsForbidden(err) || errors.IsUnauthorized(err) { + return true + } + if errors.IsServiceUnavailable(err) || errors.IsTimeout(err) || errors.IsServerTimeout(err) { + return true + } + msg := err.Error() + return strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "the server could not find the requested resource") +} + +// StorageSummary aggregates PVCs in the driver namespace into a single +// tenant-scoped storage figure. Counts every PVC in the namespace (Bound or +// Pending) toward TotalBytes (the reserved capacity the user has asked for), +// and the Bound subset toward BoundBytes. Note these are allocated capacity +// (spec.requests), not real consumption — the API cannot see guest filesystem +// usage. Returns nil without error when the driver has no k8s client (memory +// store) so the dashboard degrades gracefully. +func (d *KubeVirtDriver) StorageSummary(ctx context.Context) (*StorageSummary, error) { + if d.k8sClient == nil { + return nil, nil + } + list, err := d.k8sClient.CoreV1().PersistentVolumeClaims(d.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + if errors.IsForbidden(err) || errors.IsUnauthorized(err) { + return nil, nil + } + return nil, fmt.Errorf("list PVCs: %w", err) + } + out := &StorageSummary{Count: len(list.Items)} + for i := range list.Items { + pvc := &list.Items[i] + req := pvc.Spec.Resources.Requests.Storage() + if req == nil { + continue + } + bytes := req.Value() + out.TotalBytes += bytes + if pvc.Status.Phase == k8sv1.ClaimBound { + out.BoundBytes += bytes + } + } + out.AvailableBytes = out.TotalBytes - out.BoundBytes + return out, nil +} + +// AddonsHealth probes each critical cluster dependency (KubeVirt, CDI, Multus, +// metrics-server, networking) and reports whether its API group is registered. +// Results are memoised for the same 30s window as ClusterUsage so a dashboard +// poll only triggers one discovery round-trip per cache miss. +func (d *KubeVirtDriver) AddonsHealth(ctx context.Context) (*AddonsHealth, error) { + d.addonsCacheMu.Lock() + cached := d.addonsCache + if cached != nil && time.Since(cached.at) < clusterUsageTTL { + d.addonsCacheMu.Unlock() + return cached.value, nil + } + d.addonsCacheMu.Unlock() + + value := d.probeAddons(ctx) + + d.addonsCacheMu.Lock() + d.addonsCache = &addonsCacheEntry{value: value, at: time.Now()} + d.addonsCacheMu.Unlock() + + return value, nil +} + +// addonGroupAliases maps a substring of a CRD's spec.group to a short, +// human-friendly addon name. Entries are checked in order; the first match +// wins, so the more specific needles (e.g. "cdi.kubevirt.io") must come +// before the less specific ones (e.g. "kubevirt.io") they are a suffix of. +// Add a new entry here to teach the dashboard about a new addon that does +// not carry the Helm labels. +var addonGroupAliases = []struct { + needle string + alias string +}{ + {"cdi.kubevirt.io", "cdi"}, + {"kubevirt.io", "kubevirt"}, + {"k8s.cni.cncf.io", "multus"}, + {"cert-manager.io", "cert-manager"}, + {"istio.io", "istio"}, + {"monitoring.coreos.com", "prometheus"}, + {"tekton.dev", "tekton"}, + {"argoproj.io", "argocd"}, +} + +func aliasFromGroup(group string) string { + for _, e := range addonGroupAliases { + if strings.Contains(group, e.needle) { + return e.alias + } + } + return "" +} + +// resolveAddonName extracts an addon identifier from a CRD, in order: +// 1. app.kubernetes.io/name +// 2. app.kubernetes.io/part-of +// 3. app.kubernetes.io/component +// 4. A short alias derived from spec.group +// +// Returns "" if no identifier is found; the caller skips the CRD. +func resolveAddonName(labels map[string]string, group string) string { + for _, key := range []string{ + "app.kubernetes.io/name", + "app.kubernetes.io/part-of", + "app.kubernetes.io/component", + } { + if v := labels[key]; v != "" { + return v + } + } + return aliasFromGroup(group) +} + +// probeAddons discovers cluster addons dynamically by listing every +// CustomResourceDefinition, resolving an identifier through Helm labels +// (name/part-of/component) with a fallback to a group-prefix alias, and +// grouping by that identifier. CRDs with no resolvable name are ignored. +// +// The list is requested as a PartialObjectMetadataList: the full CRD payload +// carries every OpenAPI schema and can reach tens of MB on operator-heavy +// clusters, while the probe only needs names and labels. The API group is +// derived from the CRD object name (.), so spec.group is not +// needed. The trade-off: status.conditions is not part of ObjectMeta, so a +// CRD that exists but is not Established can no longer be told apart — a +// discovered addon is reported "ok" by presence. +// +// The probe fails soft: any error (Forbidden, timeout, missing CRD list +// permission) returns an empty list rather than failing the dashboard. +func (d *KubeVirtDriver) probeAddons(ctx context.Context) *AddonsHealth { + out := &AddonsHealth{ + Addons: []AddonHealth{}, + CheckedAt: time.Now().UTC(), + } + if d.k8sClient == nil { + return out + } + restClient := d.k8sClient.Discovery().RESTClient() + if restClient == nil { + return out + } + + callCtx, cancel := context.WithTimeout(ctx, addonsProbeCallTimeout) + defer cancel() + + raw, err := restClient. + Get(). + AbsPath("/apis/apiextensions.k8s.io/v1/customresourcedefinitions"). + SetHeader("Accept", partialObjectMetadataListAccept). + Do(callCtx). + Raw() + if err != nil { + log.Printf("[cluster-addons] list CRDs: %v", err) + return out + } + + var list struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &list); err != nil { + log.Printf("[cluster-addons] decode CRDs: %v", err) + return out + } + + seen := map[string]struct{}{} + for _, crd := range list.Items { + name := resolveAddonName(crd.Metadata.Labels, groupFromCRDName(crd.Metadata.Name)) + if name == "" { + continue + } + seen[name] = struct{}{} + } + + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + + for _, n := range names { + out.Addons = append(out.Addons, AddonHealth{Name: n, Status: "ok"}) + } + return out +} + +// groupFromCRDName extracts spec.group from a CRD object name, which is +// always . (e.g. "virtualmachines.kubevirt.io" → "kubevirt.io"). +// Returns "" when the name has no dot, which cannot happen for a real CRD. +func groupFromCRDName(name string) string { + i := strings.IndexByte(name, '.') + if i < 0 || i == len(name)-1 { + return "" + } + return name[i+1:] +} + +func isNodeReady(node *k8sv1.Node) bool { + for i := range node.Status.Conditions { + if node.Status.Conditions[i].Type == k8sv1.NodeReady { + return node.Status.Conditions[i].Status == k8sv1.ConditionTrue + } + } + return false +} + +func sortedKeys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + func (d *KubeVirtDriver) CreateVolume(ctx context.Context, name string, size string) (*Volume, error) { pvc := &k8sv1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/infra/hypervisor/storage_summary_test.go b/internal/infra/hypervisor/storage_summary_test.go new file mode 100644 index 0000000..0e77a1d --- /dev/null +++ b/internal/infra/hypervisor/storage_summary_test.go @@ -0,0 +1,104 @@ +package hypervisor + +import ( + "context" + "testing" + + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newPVC(name, size string, phase k8sv1.PersistentVolumeClaimPhase) *k8sv1.PersistentVolumeClaim { + return &k8sv1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: k8sv1.PersistentVolumeClaimSpec{ + Resources: k8sv1.VolumeResourceRequirements{ + Requests: k8sv1.ResourceList{ + k8sv1.ResourceStorage: resource.MustParse(size), + }, + }, + }, + Status: k8sv1.PersistentVolumeClaimStatus{Phase: phase}, + } +} + +func TestStorageSummaryAggregates(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset( + newPVC("vol-1", "10Gi", k8sv1.ClaimBound), + newPVC("vol-2", "20Gi", k8sv1.ClaimBound), + newPVC("vol-3", "5Gi", k8sv1.ClaimPending), + newPVC("vol-4", "1Gi", k8sv1.ClaimLost), + ) + d := &KubeVirtDriver{k8sClient: cs, namespace: "default"} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("StorageSummary: %v", err) + } + if s.Count != 4 { + t.Fatalf("count=%d want 4", s.Count) + } + wantTotal := int64((10 + 20 + 5 + 1) * 1024 * 1024 * 1024) + if s.TotalBytes != wantTotal { + t.Fatalf("total_bytes=%d want %d", s.TotalBytes, wantTotal) + } + wantBound := int64((10 + 20) * 1024 * 1024 * 1024) + if s.BoundBytes != wantBound { + t.Fatalf("bound_bytes=%d want %d", s.BoundBytes, wantBound) + } + if s.AvailableBytes != s.TotalBytes-s.BoundBytes { + t.Fatalf("available_bytes=%d want total-bound=%d", s.AvailableBytes, s.TotalBytes-s.BoundBytes) + } +} + +func TestStorageSummaryEmpty(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset() + d := &KubeVirtDriver{k8sClient: cs, namespace: "default"} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("StorageSummary: %v", err) + } + if s.Count != 0 || s.TotalBytes != 0 || s.BoundBytes != 0 || s.AvailableBytes != 0 { + t.Fatalf("expected zero-value summary, got %+v", s) + } +} + +func TestStorageSummaryNilClient(t *testing.T) { + t.Parallel() + d := &KubeVirtDriver{} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("nil client: %v", err) + } + if s != nil { + t.Fatalf("nil client must yield nil summary, got %+v", s) + } +} + +func TestStorageSummarySkipsPVCsWithoutStorageRequest(t *testing.T) { + t.Parallel() + cs := fake.NewSimpleClientset( + newPVC("vol-1", "10Gi", k8sv1.ClaimBound), + &k8sv1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "vol-bare", Namespace: "default"}, + Spec: k8sv1.PersistentVolumeClaimSpec{ + Resources: k8sv1.VolumeResourceRequirements{Requests: k8sv1.ResourceList{}}, + }, + Status: k8sv1.PersistentVolumeClaimStatus{Phase: k8sv1.ClaimBound}, + }, + ) + d := &KubeVirtDriver{k8sClient: cs, namespace: "default"} + s, err := d.StorageSummary(context.Background()) + if err != nil { + t.Fatalf("StorageSummary: %v", err) + } + if s.Count != 2 { + t.Fatalf("count=%d want 2 (PVCs without storage request must still be counted)", s.Count) + } + if s.TotalBytes != 10*1024*1024*1024 { + t.Fatalf("total_bytes=%d want 10Gi", s.TotalBytes) + } +} diff --git a/internal/infra/hypervisor/types.go b/internal/infra/hypervisor/types.go index a4b08b6..c45f35a 100644 --- a/internal/infra/hypervisor/types.go +++ b/internal/infra/hypervisor/types.go @@ -90,6 +90,71 @@ type ClusterInfo struct { MemoryTotal int64 } +type ClusterMetrics struct { + Nodes int `json:"nodes"` + NodesReady int `json:"nodes_ready"` + CPUCapacityMilli int64 `json:"cpu_capacity_millicores"` + CPUAllocatableMilli int64 `json:"cpu_allocatable_millicores"` + MemoryCapacity int64 `json:"memory_capacity_bytes"` + MemoryAllocatable int64 `json:"memory_allocatable_bytes"` + KubeletVersions []string `json:"kubelet_versions"` + OSImages []string `json:"os_images"` + OSArchitectures []string `json:"os_architectures"` + CollectedAt time.Time `json:"collected_at"` + + // Usage is populated when metrics-server is reachable. Available is false when + // the API is absent, the call is Forbidden, or the cluster has no Ready nodes + // to report. The dashboard renders a degraded state instead of failing the + // summary when Available is false. + Usage *ClusterUsage `json:"usage,omitempty"` +} + +// ClusterUsage is the sum of metrics.k8s.io/v1beta1 NodeMetrics across all +// nodes, served by metrics-server. Tier 2 of the dashboard host metrics spec. +type ClusterUsage struct { + CPUUsageMilli int64 `json:"cpu_usage_millicores"` + MemoryUsage int64 `json:"memory_usage_bytes"` + WindowSeconds int64 `json:"window_seconds"` + CollectedAt time.Time `json:"collected_at"` +} + +// StorageSummary aggregates tenant-scoped PVC capacity from the cluster. +// TotalBytes is the sum of every PVC's spec.resources.requests.storage +// (capacity the user has reserved); BoundBytes is the subset requested by +// PVCs whose status.phase is Bound. Both are allocated capacity, never real +// consumption — the API has no visibility into guest filesystem usage. +// AvailableBytes is Total - Bound (reserved but not yet bound) and is +// informational — there is no global "free" pool in K8s. +type StorageSummary struct { + TotalBytes int64 `json:"total_bytes"` + BoundBytes int64 `json:"bound_bytes"` + AvailableBytes int64 `json:"available_bytes"` + Count int `json:"count"` +} + +// AddonHealth is the status of one critical VirtFoundry dependency on the +// cluster (KubeVirt, CDI, Multus, metrics-server, networking). The dashboard +// renders a single coloured dot per addon. +type AddonHealth struct { + // Name is a stable identifier ("kubevirt", "cdi", "multus", + // "metrics-server", "networking") used by the UI for i18n lookup. + Name string `json:"name"` + // Status is one of: "ok" (installed and reachable), "absent" (CRD/API + // not registered on the cluster), "unknown" (Forbidden or transient + // failure — the UI shows a neutral dot in this case). + Status string `json:"status"` + // Detail is a short human-readable reason, sanitised server-side. It is + // only populated when Status != "ok". + Detail string `json:"detail,omitempty"` +} + +// AddonsHealth is the per-check summary of every addon the dashboard tracks. +// CheckedAt lets the UI show how stale the dots are. +type AddonsHealth struct { + Addons []AddonHealth `json:"addons"` + CheckedAt time.Time `json:"checked_at"` +} + // VMSnapshotInfo represents a KubeVirt VirtualMachineSnapshot. type VMSnapshotInfo struct { Name string diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index 861ab96..b6e7a02 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -2,12 +2,15 @@ package service import ( "context" + "log" "sort" "strings" "time" "github.com/virtfoundry/core/internal/auth" + "github.com/virtfoundry/core/internal/infra/hypervisor" "github.com/virtfoundry/core/internal/platform" + k8serrors "k8s.io/apimachinery/pkg/api/errors" ) // DashboardSummary aggregates tenant overview for the UI dashboard. @@ -19,6 +22,9 @@ type DashboardSummary struct { SecurityGroups DashboardResourceCount `json:"security_groups"` Health string `json:"health"` RecentActivity []DashboardActivity `json:"recent_activity"` + Hosts *hypervisor.ClusterMetrics `json:"hosts,omitempty"` + Storage *hypervisor.StorageSummary `json:"storage,omitempty"` + Addons *hypervisor.AddonsHealth `json:"addons,omitempty"` } type DashboardResourceCount struct { @@ -53,7 +59,7 @@ type NotificationItem struct { CreatedAt string `json:"created_at,omitempty"` } -func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, perms []string) (*DashboardSummary, error) { +func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, perms []string, role platform.Role) (*DashboardSummary, error) { summary := &DashboardSummary{ Volumes: DashboardResourceCount{Total: len(s.ListVolumes(tenantID))}, VPCs: DashboardResourceCount{Total: len(s.ListVPCs(tenantID))}, @@ -62,6 +68,17 @@ func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, Health: "ok", RecentActivity: []DashboardActivity{}, } + if role == platform.RoleRoot { + if err := s.populateHosts(ctx, summary); err != nil { + return nil, err + } + if err := s.populateStorage(ctx, summary); err != nil { + return nil, err + } + if err := s.populateAddons(ctx, summary); err != nil { + return nil, err + } + } if !auth.HasPermission(perms, auth.PermVMsRead) { return summary, nil } @@ -80,6 +97,71 @@ func (s *PlatformService) DashboardSummary(ctx context.Context, tenantID string, return summary, nil } +func (s *PlatformService) populateStorage(ctx context.Context, summary *DashboardSummary) error { + if s.kv == nil { + return nil + } + storage, err := s.kv.StorageSummary(ctx) + if err != nil { + if isExpectedHostsError(err) { + return nil + } + return err + } + summary.Storage = storage + return nil +} + +func (s *PlatformService) populateAddons(ctx context.Context, summary *DashboardSummary) error { + if s.kv == nil { + return nil + } + addons, err := s.kv.AddonsHealth(ctx) + if err != nil { + if isExpectedHostsError(err) { + return nil + } + return err + } + summary.Addons = addons + return nil +} + +func (s *PlatformService) populateHosts(ctx context.Context, summary *DashboardSummary) error { + if s.kv == nil { + return nil + } + metrics, err := s.kv.ClusterMetrics(ctx) + if err != nil { + if isExpectedHostsError(err) { + return nil + } + return err + } + summary.Hosts = metrics + // Usage is optional: any ClusterUsage error not absorbed by isMetricsAbsent + // (decode failure, unexpected transport error) must not 500 the dashboard — + // log it and leave Hosts.Usage nil so the UI renders "usage unavailable". + usage, err := s.kv.ClusterUsage(ctx) + if err != nil { + log.Printf("[dashboard] cluster usage unavailable: %v", err) + return nil + } + summary.Hosts.Usage = usage + return nil +} + +func isExpectedHostsError(err error) bool { + if err == nil { + return false + } + if k8serrors.IsForbidden(err) || k8serrors.IsUnauthorized(err) || k8serrors.IsServiceUnavailable(err) || k8serrors.IsTimeout(err) || k8serrors.IsServerTimeout(err) { + return true + } + msg := err.Error() + return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") +} + func (s *PlatformService) Search(ctx context.Context, tenantID, query string, perms []string) []SearchHit { q := strings.TrimSpace(strings.ToLower(query)) if len(q) < 2 { diff --git a/internal/service/dashboard_test.go b/internal/service/dashboard_test.go index 414d6a6..431e0a3 100644 --- a/internal/service/dashboard_test.go +++ b/internal/service/dashboard_test.go @@ -2,12 +2,23 @@ package service import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/virtfoundry/core/internal/auth" + "github.com/virtfoundry/core/internal/infra/hypervisor" "github.com/virtfoundry/core/internal/platform" "github.com/virtfoundry/core/internal/platform/store" + k8sv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" ) func TestCountVMStates(t *testing.T) { @@ -73,7 +84,7 @@ func seedDashboardVMs(t *testing.T) (*PlatformService, string) { func TestDashboardSummaryHidesVMNamesWithoutVMsRead(t *testing.T) { svc, tenantID := seedDashboardVMs(t) - summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, "") if err != nil { t.Fatalf("DashboardSummary: %v", err) } @@ -89,7 +100,7 @@ func TestDashboardSummaryHidesVMNamesWithoutVMsRead(t *testing.T) { func TestDashboardSummaryIncludesVMsWithVMsRead(t *testing.T) { svc, tenantID := seedDashboardVMs(t) - summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, "") if err != nil { t.Fatalf("DashboardSummary: %v", err) } @@ -121,3 +132,223 @@ func TestNotificationsHidesVMNamesWithoutVMsRead(t *testing.T) { t.Fatal("expected secret-vm notification with vms:read") } } + +func TestDashboardSummaryHostsNilWithoutDriver(t *testing.T) { + svc, tenantID := seedDashboardVMs(t) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, "") + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts != nil { + t.Fatalf("expected hosts=nil when driver is nil, got %+v", summary.Hosts) + } +} + +func TestDashboardSummaryHostsPopulatedWithDriver(t *testing.T) { + st := store.NewMemory() + tenantID := store.NewID() + st.SaveTenant(&platform.Tenant{ + ID: tenantID, Name: "acme", Slug: "acme", Namespace: "vf-acme", + State: "active", CreatedAt: store.Now(), + }) + cs := fake.NewSimpleClientset( + &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: k8sv1.NodeStatus{ + Capacity: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("4"), + k8sv1.ResourceMemory: resource.MustParse("16Gi"), + }, + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("3500m"), + k8sv1.ResourceMemory: resource.MustParse("14Gi"), + }, + NodeInfo: k8sv1.NodeSystemInfo{ + KubeletVersion: "v1.30.0", + OSImage: "Ubuntu 22.04", + Architecture: "amd64", + }, + Conditions: []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + }, + }, + }, + ) + kv := hypervisor.NewKubeVirtDriverForTest(nil, cs) + svc := NewPlatformService(st, nil, kv, nil) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts == nil { + t.Fatal("expected hosts populated when driver returns metrics") + } + if summary.Hosts.Nodes != 1 || summary.Hosts.NodesReady != 1 { + t.Fatalf("nodes=%d ready=%d want 1/1", summary.Hosts.Nodes, summary.Hosts.NodesReady) + } + if summary.Hosts.CPUAllocatableMilli != 3500 { + t.Fatalf("cpu_allocatable_millicores=%d want 3500", summary.Hosts.CPUAllocatableMilli) + } + if summary.Hosts.Usage != nil { + t.Fatalf("expected usage=nil with fake clientset (no RESTClient), got %+v", summary.Hosts.Usage) + } +} + +func TestDashboardSummaryUsagePopulated(t *testing.T) { + st := store.NewMemory() + tenantID := store.NewID() + st.SaveTenant(&platform.Tenant{ + ID: tenantID, Name: "acme", Slug: "acme", Namespace: "vf-acme", + State: "active", CreatedAt: store.Now(), + }) + node := &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: k8sv1.NodeStatus{ + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("3500m"), + k8sv1.ResourceMemory: resource.MustParse("14Gi"), + }, + Conditions: []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [` + nodeJSON(node) + `]}`)) + case r.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [{"usage": {"cpu": "1234m", "memory": "7Gi"}}]}`)) + case strings.HasPrefix(r.URL.Path, "/api/v1/namespaces/default/persistentvolumeclaims"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": []}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + cs.Discovery() // warm-up: not strictly required, documents intent + + kv := hypervisor.NewKubeVirtDriverForTest(nil, cs) + svc := NewPlatformService(st, nil, kv, nil) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts == nil { + t.Fatal("expected hosts populated") + } + if summary.Hosts.Usage == nil { + t.Fatal("expected usage populated when metrics.k8s.io is reachable") + } + if summary.Hosts.Usage.CPUUsageMilli != 1234 { + t.Fatalf("cpu_usage_millicores=%d want 1234", summary.Hosts.Usage.CPUUsageMilli) + } + if summary.Hosts.Usage.MemoryUsage != 7*1024*1024*1024 { + t.Fatalf("memory_usage_bytes=%d want 7Gi", summary.Hosts.Usage.MemoryUsage) + } +} + +func TestDashboardSummaryUsageErrorDoesNotFail(t *testing.T) { + // metrics.k8s.io answers with an undecodable body — not a Status error, so + // isMetricsAbsent does not absorb it. Usage is optional: the dashboard must + // still succeed with hosts populated and hosts.usage left nil. + st := store.NewMemory() + tenantID := store.NewID() + st.SaveTenant(&platform.Tenant{ + ID: tenantID, Name: "acme", Slug: "acme", Namespace: "vf-acme", + State: "active", CreatedAt: store.Now(), + }) + node := &k8sv1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "n1"}, + Status: k8sv1.NodeStatus{ + Allocatable: k8sv1.ResourceList{ + k8sv1.ResourceCPU: resource.MustParse("3500m"), + k8sv1.ResourceMemory: resource.MustParse("14Gi"), + }, + Conditions: []k8sv1.NodeCondition{ + {Type: k8sv1.NodeReady, Status: k8sv1.ConditionTrue}, + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": [` + nodeJSON(node) + `]}`)) + case r.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ this is not json`)) + case strings.HasPrefix(r.URL.Path, "/api/v1/namespaces/default/persistentvolumeclaims"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items": []}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + cfg := &rest.Config{Host: srv.URL} + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("build client: %v", err) + } + + kv := hypervisor.NewKubeVirtDriverForTest(nil, cs) + svc := NewPlatformService(st, nil, kv, nil) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVolumesRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("usage failure must not fail DashboardSummary: %v", err) + } + if summary.Hosts == nil { + t.Fatal("expected hosts populated even when usage fails") + } + if summary.Hosts.Usage != nil { + t.Fatalf("expected usage=nil on unexpected metrics error, got %+v", summary.Hosts.Usage) + } +} + +func nodeJSON(n *k8sv1.Node) string { + n.TypeMeta = metav1.TypeMeta{Kind: "Node", APIVersion: "v1"} + b, _ := json.Marshal(n) + return string(b) +} + +func TestDashboardSummaryHidesClusterOverviewForNonRoot(t *testing.T) { + svc, tenantID := seedDashboardVMs(t) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, platform.RoleTenantAdmin) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts != nil { + t.Fatalf("expected hosts=nil for non-root, got %+v", summary.Hosts) + } + if summary.Storage != nil { + t.Fatalf("expected storage=nil for non-root, got %+v", summary.Storage) + } + if summary.Addons != nil { + t.Fatalf("expected addons=nil for non-root, got %+v", summary.Addons) + } + if summary.VMs.Total == 0 { + t.Fatal("non-root with vms:read must still see VM counts") + } +} + +func TestDashboardSummaryShowsClusterOverviewForRoot(t *testing.T) { + svc, tenantID := seedDashboardVMs(t) + summary, err := svc.DashboardSummary(context.Background(), tenantID, []string{auth.PermVMsRead}, platform.RoleRoot) + if err != nil { + t.Fatalf("DashboardSummary: %v", err) + } + if summary.Hosts != nil { + t.Fatal("driver is nil in seedDashboardVMs so hosts stays nil, that's fine") + } +} diff --git a/internal/service/platform.go b/internal/service/platform.go index 769fd09..46b4958 100644 --- a/internal/service/platform.go +++ b/internal/service/platform.go @@ -38,6 +38,7 @@ type PlatformService struct { jobs *jobs.Service sshkeys *sshkeys.Service lb *loadbalancer.Service + kv *hypervisor.KubeVirtDriver } func NewPlatformService(st store.Repository, k8s *platformk8s.Manager, kv *hypervisor.KubeVirtDriver, hub EventBroadcaster) *PlatformService { @@ -54,6 +55,7 @@ func NewPlatformService(st store.Repository, k8s *platformk8s.Manager, kv *hyper jobs: jobs.New(st, computeSvc), sshkeys: sshkeys.New(st), lb: loadbalancer.New(st, k8s), + kv: kv, } } diff --git a/ui/src/lib/i18n.tsx b/ui/src/lib/i18n.tsx index 5fa2c90..1dd7a0d 100644 --- a/ui/src/lib/i18n.tsx +++ b/ui/src/lib/i18n.tsx @@ -106,6 +106,34 @@ export const dict = { 'dashboard.newVpc': 'Nova VPC', 'dashboard.snapshot': 'Snapshot', 'dashboard.running': 'em execução', + 'dashboard.storageTitle': 'Storage', + 'dashboard.storageBoundOf': '{bound} de {total} alocados', + 'dashboard.storageCount': '{n} volumes', + 'dashboard.addonsTitle': 'Cluster addons', + 'dashboard.addonsKubevirt': 'KubeVirt', + 'dashboard.addonsCdi': 'CDI', + 'dashboard.addonsMultus': 'Multus', + 'dashboard.addonsMetricsServer': 'metrics-server', + 'dashboard.addonsNetworking': 'Networking', + 'dashboard.addonsCertManager': 'cert-manager', + 'dashboard.addonsStatusOk': 'OK', + 'dashboard.addonsStatusAbsent': 'ausente', + 'dashboard.addonsStatusUnknown': 'sem resposta', + 'dashboard.hostsTitle': 'Cluster overview', + 'dashboard.hostsNodes': 'Nodes', + 'dashboard.hostsNodesReady': 'Nodes prontos', + 'dashboard.hostsNodesAllReady': 'Todos prontos', + 'dashboard.hostsNodesSomeDegraded': '{n} degradado(s)', + 'dashboard.hostsNodesNoneReady': 'Nenhum pronto', + 'dashboard.hostsCpu': 'CPU alocável', + 'dashboard.hostsMemory': 'Memória alocável', + 'dashboard.hostsAllocatableHint': 'Soma de Status.Allocatable de todos os nodes do cluster.', + 'dashboard.hostsUsage': 'Em uso', + 'dashboard.hostsUsageUnavailable': 'metrics-server indisponível', + 'dashboard.hostsUnavailable': 'Cluster indisponível', + 'dashboard.hostsKubelet': 'Kubelet', + 'dashboard.hostsOs': 'Imagem do SO', + 'dashboard.hostsArch': 'Arquitetura', 'header.searchPlaceholder': 'Buscar recursos...', 'header.searchEmpty': 'Nenhum resultado', 'header.notifications': 'Notificações', @@ -742,6 +770,34 @@ export const dict = { 'dashboard.newVpc': 'New VPC', 'dashboard.snapshot': 'Snapshot', 'dashboard.running': 'running', + 'dashboard.storageTitle': 'Storage', + 'dashboard.storageBoundOf': '{bound} of {total} allocated', + 'dashboard.storageCount': '{n} volumes', + 'dashboard.addonsTitle': 'Cluster addons', + 'dashboard.addonsKubevirt': 'KubeVirt', + 'dashboard.addonsCdi': 'CDI', + 'dashboard.addonsMultus': 'Multus', + 'dashboard.addonsMetricsServer': 'metrics-server', + 'dashboard.addonsNetworking': 'Networking', + 'dashboard.addonsCertManager': 'cert-manager', + 'dashboard.addonsStatusOk': 'OK', + 'dashboard.addonsStatusAbsent': 'absent', + 'dashboard.addonsStatusUnknown': 'unreachable', + 'dashboard.hostsTitle': 'Cluster overview', + 'dashboard.hostsNodes': 'Nodes', + 'dashboard.hostsNodesReady': 'Ready nodes', + 'dashboard.hostsNodesAllReady': 'All ready', + 'dashboard.hostsNodesSomeDegraded': '{n} degraded', + 'dashboard.hostsNodesNoneReady': 'None ready', + 'dashboard.hostsCpu': 'Allocatable CPU', + 'dashboard.hostsMemory': 'Allocatable memory', + 'dashboard.hostsAllocatableHint': 'Sum of Status.Allocatable across all cluster nodes.', + 'dashboard.hostsUsage': 'Usage', + 'dashboard.hostsUsageUnavailable': 'metrics-server unavailable', + 'dashboard.hostsUnavailable': 'Cluster unavailable', + 'dashboard.hostsKubelet': 'Kubelet', + 'dashboard.hostsOs': 'OS image', + 'dashboard.hostsArch': 'Architecture', 'header.searchPlaceholder': 'Search resources...', 'header.searchEmpty': 'No results', 'header.notifications': 'Notifications', diff --git a/ui/src/lib/platform-api.ts b/ui/src/lib/platform-api.ts index 5fdefde..4e69744 100644 --- a/ui/src/lib/platform-api.ts +++ b/ui/src/lib/platform-api.ts @@ -681,6 +681,48 @@ export interface DashboardSummary { updated_at?: string; path: string; }>; + hosts?: ClusterMetrics; + storage?: StorageSummary; + addons?: AddonsHealth; +} + +export interface ClusterMetrics { + nodes: number; + nodes_ready: number; + cpu_capacity_millicores: number; + cpu_allocatable_millicores: number; + memory_capacity_bytes: number; + memory_allocatable_bytes: number; + kubelet_versions: string[]; + os_images: string[]; + os_architectures: string[]; + collected_at: string; + usage?: ClusterUsage; +} + +export interface ClusterUsage { + cpu_usage_millicores: number; + memory_usage_bytes: number; + window_seconds: number; + collected_at: string; +} + +export interface StorageSummary { + total_bytes: number; + bound_bytes: number; + available_bytes: number; + count: number; +} + +export interface AddonHealth { + name: string; + status: 'ok' | 'absent' | 'unknown'; + detail?: string; +} + +export interface AddonsHealth { + addons: AddonHealth[]; + checked_at: string; } export interface SearchHit { diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index bd6ec2a..65f95ad 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,12 +1,14 @@ -import { HardDrive, Globe, Shield, Network, CheckCircle, AlertTriangle, RefreshCw } from 'lucide-react'; +import { HardDrive, Globe, Shield, Server, Cpu, MemoryStick, CheckCircle, AlertTriangle, RefreshCw } from 'lucide-react'; import clsx from 'clsx'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { getDashboardSummary } from '../lib/platform-api'; import { useNeedsTenant } from '../store/hooks'; +import { useAppSelector } from '../store/hooks'; +import { selectIsRoot } from '../store/authSlice'; import { queryKeys } from '../lib/query-keys'; import { RefreshingPanel } from '../components/RefreshingPanel'; -import { useI18n } from '../lib/i18n'; +import { useI18n, type TranslationKey } from '../lib/i18n'; import { PageHeader, SurfaceCard } from '../components/shell'; import { OnboardingChecklist } from '../components/OnboardingChecklist'; @@ -14,14 +16,129 @@ import { getRecentActions } from '../lib/preview-prefs'; import { ComingSoonBadge } from '../components/ComingSoonBadge'; import { useMemo } from 'react'; import { - POLL_WS_HEALTHY_SLOW_MS, - realtimePollInterval, useRealtimeConnected, } from '../hooks/useRealtimeEvents'; +function usageTone(pct: number): string { + if (pct >= 85) return 'bg-error'; + if (pct >= 60) return 'bg-warning'; + return 'bg-primary'; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '—'; + if (bytes === 0) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +function UsageBar({ pct, collectedAt }: { pct: number; collectedAt?: string }) { + const clamped = Math.min(100, Math.max(0, pct)); + return ( +
+
+
+
+

+ {clamped}% +

+
+ ); +} + +type NodeHealthTone = { + badge: string; + label: TranslationKey; +}; + +function nodeHealth(total: number, ready: number): NodeHealthTone | null { + if (total <= 0) return null; + if (ready >= total) { + return { badge: 'bg-success-muted text-success border-success/20', label: 'dashboard.hostsNodesAllReady' }; + } + if (ready <= 0) { + return { badge: 'bg-error-container/30 text-error border-error/30', label: 'dashboard.hostsNodesNoneReady' }; + } + return { badge: 'bg-warning-muted text-warning border-warning/20', label: 'dashboard.hostsNodesSomeDegraded' }; +} + +function NodesHealthBadge({ total, ready }: { total: number; ready: number }) { + const { t } = useI18n(); + const tone = nodeHealth(total, ready); + if (!tone) return null; + const label = tone.label === 'dashboard.hostsNodesSomeDegraded' + ? t(tone.label).replace('{n}', String(total - ready)) + : t(tone.label); + return ( + + {label} + + ); +} + +const ADDON_LABEL_KEY: Record = { + kubevirt: 'dashboard.addonsKubevirt', + cdi: 'dashboard.addonsCdi', + multus: 'dashboard.addonsMultus', + 'metrics-server': 'dashboard.addonsMetricsServer', + networking: 'dashboard.addonsNetworking', + 'cert-manager': 'dashboard.addonsCertManager', +}; + +function addonTone(status: string): { dot: string; pulse: boolean; labelKey: TranslationKey } { + if (status === 'ok') return { dot: 'bg-success', pulse: true, labelKey: 'dashboard.addonsStatusOk' }; + if (status === 'absent') return { dot: 'bg-transparent border border-on-surface-variant/60', pulse: false, labelKey: 'dashboard.addonsStatusAbsent' }; + return { dot: 'bg-warning', pulse: true, labelKey: 'dashboard.addonsStatusUnknown' }; +} + +function AddonsHealthStrip({ addons }: { addons: { addons: Array<{ name: string; status: string; detail?: string }>; checked_at: string } }) { + const { t } = useI18n(); + const available = addons.addons.filter((a) => a.status === 'ok'); + if (available.length === 0) return null; + return ( +
+

+ {t('dashboard.addonsTitle')} +

+
+ {available.map((a) => { + const tone = addonTone(a.status); + const labelKey = ADDON_LABEL_KEY[a.name] ?? null; + const label = labelKey ? t(labelKey) : a.name; + const statusLabel = t(tone.labelKey); + return ( +
+ + + {label} + +
+ ); + })} +
+
+ ); +} + export function Dashboard() { const { t } = useI18n(); const needsTenant = useNeedsTenant(); + const isRoot = useAppSelector(selectIsRoot); const enabled = !needsTenant; const wsConnected = useRealtimeConnected(); const localRecent = useMemo(() => getRecentActions(), []); @@ -30,14 +147,11 @@ export function Dashboard() { queryKey: queryKeys.dashboardSummary, queryFn: getDashboardSummary, enabled, - refetchInterval: (q) => { - const health = q.state.data?.health; - const unhealthy = health === 'warning' || health === 'critical'; - return realtimePollInterval(wsConnected, unhealthy, { - downMs: 5_000, - healthyMs: POLL_WS_HEALTHY_SLOW_MS, - }); - }, + // Cluster usage comes from metrics.k8s.io polled every ~15s — there is no + // platform event for it, so the dashboard must keep polling regardless of + // /ws/events health. The WS still invalidates VM counts on transitions + // (see lib/realtime-invalidation), so the two paths compose. + refetchInterval: () => (wsConnected ? 10_000 : 5_000), }); const vms = summary?.vms ?? { total: 0, running: 0, error: 0 }; @@ -49,10 +163,35 @@ export function Dashboard() { { label: t('nav.volumes'), value: summary?.volumes.total ?? 0, icon: HardDrive }, { label: t('nav.vpcs'), value: summary?.vpcs.total ?? 0, icon: Globe }, { label: t('nav.securityGroups'), value: summary?.security_groups.total ?? 0, icon: Shield }, - { label: t('nav.networks'), value: summary?.networks.total ?? 0, icon: Network }, ]; const recentVms = summary?.recent_activity ?? []; + const hosts = summary?.hosts; + const hostsUnavailable = hosts === undefined; + const cpuCores = hosts ? (hosts.cpu_allocatable_millicores / 1000).toFixed(1) : '0.0'; + const storage = summary?.storage; + const storageBound = storage ? formatBytes(storage.bound_bytes) : '—'; + const storageTotal = storage ? formatBytes(storage.total_bytes) : '—'; + const storagePct = storage && storage.total_bytes > 0 + ? Math.round((storage.bound_bytes / storage.total_bytes) * 100) + : null; + const memGiB = hosts ? Math.round(hosts.memory_allocatable_bytes / (1024 * 1024 * 1024)) : 0; + const kubeletSummary = hosts?.kubelet_versions?.length + ? hosts.kubelet_versions.length === 1 + ? hosts.kubelet_versions[0] + : `${hosts.kubelet_versions.length} versões` + : ''; + const osImage = hosts?.os_images?.[0] ?? ''; + const arch = hosts?.os_architectures?.[0] ?? ''; + const usage = hosts?.usage; + const cpuUsageCores = usage ? (usage.cpu_usage_millicores / 1000).toFixed(1) : null; + const memUsageGiB = usage ? Math.round(usage.memory_usage_bytes / (1024 * 1024 * 1024)) : null; + const cpuUsagePct = usage && hosts && hosts.cpu_allocatable_millicores > 0 + ? Math.round((usage.cpu_usage_millicores / hosts.cpu_allocatable_millicores) * 100) + : null; + const memUsagePct = usage && hosts && hosts.memory_allocatable_bytes > 0 + ? Math.round((usage.memory_usage_bytes / hosts.memory_allocatable_bytes) * 100) + : null; if (needsTenant) { return ( @@ -158,6 +297,34 @@ export function Dashboard() {
))} + +
+ + {storage && storagePct !== null ? ( + <> + + {t('dashboard.storageBoundOf').replace('{bound}', storageBound).replace('{total}', storageTotal)} + + + {storagePct}% + + + {t('dashboard.storageCount').replace('{n}', String(storage.count))} + + + ) : ( + <> + + {t('dashboard.storageTitle')} + + — + + )} +
+
@@ -220,6 +387,120 @@ export function Dashboard() { ))} + + {isRoot && ( + + {hostsUnavailable ? ( +

+ {t('dashboard.hostsUnavailable')} +

+ ) : ( + <> +
+
+ + + {t('dashboard.hostsNodes')} + + + {hosts?.nodes ?? 0} + +
+
+
+ + + {t('dashboard.hostsNodesReady')} + + + {hosts?.nodes_ready ?? 0} + + +
+
+
+ + + {t('dashboard.hostsCpu')} + + + {cpuCores} + +
+ {cpuUsageCores !== null ? ( + + {cpuUsageCores} {t('dashboard.hostsUsage')} + + ) : ( + + )} + {cpuUsagePct !== null ? ( + + ) : ( + + {t('dashboard.hostsUsageUnavailable')} + + )} +
+
+ + + {t('dashboard.hostsMemory')} + + + {memGiB} GiB + +
+ {memUsageGiB !== null ? ( + + {memUsageGiB} GiB {t('dashboard.hostsUsage')} + + ) : ( + + )} + {memUsagePct !== null ? ( + + ) : ( + + {t('dashboard.hostsUsageUnavailable')} + + )} +
+
+ {(kubeletSummary || osImage || arch) && ( +

+ {[ + kubeletSummary && `${t('dashboard.hostsKubelet')}: ${kubeletSummary}`, + osImage && `${t('dashboard.hostsOs')}: ${osImage}`, + arch && `${t('dashboard.hostsArch')}: ${arch}`, + ] + .filter(Boolean) + .join(' · ')} +

+ )} + {summary?.addons && } + + )} + + )}