Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.81.0
Expand Down Expand Up @@ -158,7 +159,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
Expand Down
93 changes: 9 additions & 84 deletions internal/server/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package server

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
Expand Down Expand Up @@ -1442,11 +1441,6 @@ func (s *Server) countWarningEvents(cache *k8s.ResourceCache, namespace string)
return count
}

// metricsServerTimeout bounds the metrics-server query so a slow or unreachable
// metrics endpoint can't consume the dashboard's whole request budget (the
// handler runs this in a goroutine and blocks on it in wg.Wait()).
const metricsServerTimeout = 8 * time.Second

func (s *Server) getDashboardMetrics(ctx context.Context, allowedNamespaces []string) *DashboardMetrics {
// Node capacity and pod requests come from the cache — they don't need
// metrics-server. Live usage does. Compute the cached values first and
Expand All @@ -1465,84 +1459,21 @@ func (s *Server) getDashboardMetrics(ctx context.Context, allowedNamespaces []st
return nil
}

// Sum capacity across all nodes (cached)
var cpuCapacityMillis int64
var memCapacityBytes int64
for _, n := range nodes {
cpuCapacityMillis += n.Status.Capacity.Cpu().MilliValue()
memCapacityBytes += n.Status.Capacity.Memory().Value()
}

// Sum requests across pods the user can see (cached). For namespace-
// restricted users this scopes to allowedNamespaces — without it, the
// dashboard would expose aggregate pod-resource totals from namespaces they
// have no read access to.
var cpuRequestsMillis int64
var memRequestsBytes int64
var metricPods []*corev1.Pod
if podLister := cache.Pods(); podLister != nil {
if allowedNamespaces == nil {
metricPods, _ = podLister.List(labels.Everything())
} else {
for _, ns := range allowedNamespaces {
items, _ := podLister.Pods(ns).List(labels.Everything())
metricPods = append(metricPods, items...)
}
}
}
for _, pod := range metricPods {
// Skip completed/failed pods
if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
continue
}
for _, container := range pod.Spec.Containers {
if container.Resources.Requests != nil {
if cpu, ok := container.Resources.Requests[corev1.ResourceCPU]; ok {
cpuRequestsMillis += cpu.MilliValue()
}
if mem, ok := container.Resources.Requests[corev1.ResourceMemory]; ok {
memRequestsBytes += mem.Value()
}
}
}
}
// Capacity + scheduled-pod requests via the shared informer-derived
// computation (node_metrics.go). Namespace scoping keeps restricted users
// from seeing aggregate totals of namespaces they can't read.
cr := computeCapacityRequests(nodes, listPodsScoped(cache.Pods(), allowedNamespaces))
cpuCapacityMillis := cr.cpuCapMillis
memCapacityBytes := cr.memCapBytes
cpuRequestsMillis := cr.cpuReqMillis
memRequestsBytes := cr.memReqBytes

// Live usage from metrics-server — best-effort. Query via raw REST to avoid
// adding k8s.io/metrics dependency; metrics-server forwards impersonation
// headers, so a user without metrics.k8s.io/nodes access gets a 403. A
// missing, forbidden, or slow metrics-server leaves usageAvailable false
// rather than discarding the capacity/request data computed above.
var cpuUsageMillis int64
var memUsageBytes int64
usageAvailable := false
if client := k8s.ClientFromContext(ctx); client != nil {
mctx, cancel := context.WithTimeout(ctx, metricsServerTimeout)
defer cancel()
data, err := client.CoreV1().RESTClient().Get().
AbsPath("/apis/metrics.k8s.io/v1beta1/nodes").
DoRaw(mctx)
if err != nil {
log.Printf("[dashboard] node metrics unavailable (showing requests/capacity only): %v", err)
} else {
var nodeMetricsList struct {
Items []struct {
Usage struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
} `json:"usage"`
} `json:"items"`
}
if err := json.Unmarshal(data, &nodeMetricsList); err != nil {
log.Printf("[dashboard] failed to parse node metrics: %v", err)
} else if len(nodeMetricsList.Items) > 0 {
for _, item := range nodeMetricsList.Items {
cpuUsageMillis += parseCPUToMillis(item.Usage.CPU)
memUsageBytes += parseMemoryToBytes(item.Usage.Memory)
}
usageAvailable = true
}
}
}
cpuUsageMillis, memUsageBytes, usageAvailable := s.fetchNodeUsage(ctx)

metrics := &DashboardMetrics{UsageAvailable: usageAvailable}
if cpuCapacityMillis > 0 {
Expand Down Expand Up @@ -1571,12 +1502,6 @@ func (s *Server) getDashboardMetrics(ctx context.Context, allowedNamespaces []st
return metrics
}

// parseCPUToMillis delegates to k8s.ParseCPUToMillis.
func parseCPUToMillis(s string) int64 { return k8s.ParseCPUToMillis(s) }

// parseMemoryToBytes delegates to k8s.ParseMemoryToBytes.
func parseMemoryToBytes(s string) int64 { return k8s.ParseMemoryToBytes(s) }

// Helper functions

// collectClusterScopedCRDCounts returns counts of cluster-scoped CRD instances
Expand Down
1 change: 1 addition & 0 deletions internal/server/namespace_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func (s *Server) finalizePostContextSwitch() {
k8s.InvalidateUserCapabilitiesCache()
clearPackagesCache()
clearApplicationsCache()
s.vitalsMetrics.clear()
s.clearAllNamespacePreferences()
}

Expand Down
124 changes: 124 additions & 0 deletions internal/server/node_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package server

import (
"context"
"encoding/json"
"log"
"time"

"github.com/skyhook-io/radar/internal/k8s"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/labels"
v1listers "k8s.io/client-go/listers/core/v1"
)

// Shared metrics-server plumbing — consumed by both /api/dashboard and
// /api/vitals. Lives in neither handler file on purpose: the probe is a
// cluster-metrics concern, not a feature of either endpoint.

// metricsServerTimeout bounds the metrics-server query so a slow or unreachable
// metrics endpoint can't consume the dashboard's whole request budget (the
// handler runs this in a goroutine and blocks on it in wg.Wait()).
const metricsServerTimeout = 8 * time.Second

// fetchNodeUsage probes metrics-server for live node usage (impersonated —
// a caller without metrics.k8s.io access gets ok=false, not someone else's
// numbers). Extracted so /api/vitals can memoize JUST this probe while
// computing capacity/requests fresh from the informer cache on every call.
func (s *Server) fetchNodeUsage(ctx context.Context) (cpuMillis, memBytes int64, ok bool) {
client := k8s.ClientFromContext(ctx)
if client == nil {
return 0, 0, false
}
mctx, cancel := context.WithTimeout(ctx, metricsServerTimeout)
defer cancel()
data, err := client.CoreV1().RESTClient().Get().
AbsPath("/apis/metrics.k8s.io/v1beta1/nodes").
DoRaw(mctx)
if err != nil {
log.Printf("[dashboard] node metrics unavailable (showing requests/capacity only): %v", err)
return 0, 0, false
}
var nodeMetricsList struct {
Items []struct {
Usage struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
} `json:"usage"`
} `json:"items"`
}
if err := json.Unmarshal(data, &nodeMetricsList); err != nil {
log.Printf("[dashboard] failed to parse node metrics: %v", err)
return 0, 0, false
}
if len(nodeMetricsList.Items) == 0 {
return 0, 0, false
}
for _, item := range nodeMetricsList.Items {
cpuMillis += parseCPUToMillis(item.Usage.CPU)
memBytes += parseMemoryToBytes(item.Usage.Memory)
}
return cpuMillis, memBytes, true
}

// parseCPUToMillis delegates to k8s.ParseCPUToMillis.
func parseCPUToMillis(s string) int64 { return k8s.ParseCPUToMillis(s) }

// parseMemoryToBytes delegates to k8s.ParseMemoryToBytes.
func parseMemoryToBytes(s string) int64 { return k8s.ParseMemoryToBytes(s) }

// listPodsScoped lists pods either cluster-wide (namespaces nil) or across
// the caller's allowed namespaces — the scoping shape every metrics/vitals
// consumer shares.
func listPodsScoped(podLister v1listers.PodLister, namespaces []string) []*corev1.Pod {
if podLister == nil {
return nil
}
// Sentinel contract (parseNamespacesForUser): nil = all namespaces;
// non-nil EMPTY = no namespace access — zero pods, never cluster-wide.
if namespaces == nil {
pods, _ := podLister.List(labels.Everything())
return pods
}
var pods []*corev1.Pod
for _, ns := range namespaces {
items, _ := podLister.Pods(ns).List(labels.Everything())
pods = append(pods, items...)
}
return pods
}

// capacityRequests carries the informer-derived halves of the capacity
// picture: node capacity plus scheduled-pod requests (completed pods
// excluded). Usage is the metrics-server probe's job (fetchNodeUsage).
type capacityRequests struct {
cpuCapMillis int64
memCapBytes int64
cpuReqMillis int64
memReqBytes int64
}

func computeCapacityRequests(nodes []*corev1.Node, pods []*corev1.Pod) capacityRequests {
var cr capacityRequests
for _, n := range nodes {
cr.cpuCapMillis += n.Status.Capacity.Cpu().MilliValue()
cr.memCapBytes += n.Status.Capacity.Memory().Value()
}
for _, pod := range pods {
if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
continue
}
for _, c := range pod.Spec.Containers {
if c.Resources.Requests == nil {
continue
}
if cpu, ok := c.Resources.Requests[corev1.ResourceCPU]; ok {
cr.cpuReqMillis += cpu.MilliValue()
}
if mem, ok := c.Resources.Requests[corev1.ResourceMemory]; ok {
cr.memReqBytes += mem.Value()
}
}
}
return cr
}
70 changes: 70 additions & 0 deletions internal/server/node_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package server

import (
"testing"

"k8s.io/apimachinery/pkg/api/resource"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes/fake"
)

func TestListPodsScoped_SentinelContract(t *testing.T) {
client := fake.NewSimpleClientset(
&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "ns1"}},
&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "ns2"}},
)
f := informers.NewSharedInformerFactory(client, 0)
informer := f.Core().V1().Pods()
_ = informer.Informer()
stop := make(chan struct{})
defer close(stop)
f.Start(stop)
f.WaitForCacheSync(stop)
lister := informer.Lister()

if got := listPodsScoped(lister, nil); len(got) != 2 {
t.Errorf("nil scope = %d pods, want 2 (cluster-wide)", len(got))
}
if got := listPodsScoped(lister, []string{}); len(got) != 0 {
t.Errorf("empty scope = %d pods, want 0 (no namespace access)", len(got))
}
if got := listPodsScoped(lister, []string{"ns1"}); len(got) != 1 {
t.Errorf("ns1 scope = %d pods, want 1", len(got))
}
if got := listPodsScoped(nil, nil); got != nil {
t.Errorf("nil lister must yield nil")
}
}

func TestComputeCapacityRequests_ExcludesCompletedPods(t *testing.T) {
nodes := []*corev1.Node{{Status: corev1.NodeStatus{Capacity: corev1.ResourceList{
corev1.ResourceCPU: mustQty(t, "4"), corev1.ResourceMemory: mustQty(t, "8Gi"),
}}}}
req := corev1.ResourceList{corev1.ResourceCPU: mustQty(t, "500m"), corev1.ResourceMemory: mustQty(t, "1Gi")}
running := &corev1.Pod{
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Resources: corev1.ResourceRequirements{Requests: req}}}},
}
done := running.DeepCopy()
done.Status.Phase = corev1.PodSucceeded

cr := computeCapacityRequests(nodes, []*corev1.Pod{running, done})
if cr.cpuCapMillis != 4000 {
t.Errorf("cpuCap = %d, want 4000", cr.cpuCapMillis)
}
if cr.cpuReqMillis != 500 {
t.Errorf("cpuReq = %d, want 500 (completed pod excluded)", cr.cpuReqMillis)
}
}

func mustQty(t *testing.T, s string) resource.Quantity {
t.Helper()
q, err := resource.ParseQuantity(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return q
}
2 changes: 2 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import (
type Server struct {
router *chi.Mux
broadcaster *SSEBroadcaster
vitalsMetrics vitalsMetricsMemo
port int
devMode bool
staticFS fs.FS
Expand Down Expand Up @@ -291,6 +292,7 @@ func (s *Server) setupRoutes() {
r.Get("/auth/me", s.handleAuthMe)
r.Get("/version-check", s.handleVersionCheck)
r.Get("/dashboard", s.handleDashboard)
r.Get("/vitals", s.handleVitals)
r.Get("/dashboard/crds", s.handleDashboardCRDs)
r.Get("/dashboard/helm", s.handleDashboardHelm)
r.Get("/cluster-info", s.handleClusterInfo)
Expand Down
Loading
Loading