From ab4bdcbdb302c44538010d4f06df5ca41d154e0a Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 17:15:57 +0300 Subject: [PATCH 01/12] Add workload cost tab --- internal/opencost/handlers.go | 6 +- internal/server/opencost_workload.go | 232 ++++++++++++ internal/server/opencost_workload_test.go | 45 +++ internal/server/server.go | 2 + .../src/components/workload/WorkloadView.tsx | 18 +- pkg/opencost/types.go | 24 +- pkg/opencost/workload_trend.go | 156 ++++++++ pkg/opencost/workload_trend_test.go | 98 +++++ web/src/api/client.ts | 42 +++ .../components/cost/WorkloadCostTab.test.ts | 101 +++++ web/src/components/cost/WorkloadCostTab.tsx | 346 ++++++++++++++++++ web/src/components/workload/WorkloadView.tsx | 9 + 12 files changed, 1074 insertions(+), 5 deletions(-) create mode 100644 internal/server/opencost_workload.go create mode 100644 internal/server/opencost_workload_test.go create mode 100644 pkg/opencost/workload_trend.go create mode 100644 pkg/opencost/workload_trend_test.go create mode 100644 web/src/components/cost/WorkloadCostTab.test.ts create mode 100644 web/src/components/cost/WorkloadCostTab.tsx diff --git a/internal/opencost/handlers.go b/internal/opencost/handlers.go index c6ec8c8f2..d89009640 100644 --- a/internal/opencost/handlers.go +++ b/internal/opencost/handlers.go @@ -67,13 +67,13 @@ func handleWorkloads(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, pkgopencost.ComputeWorkloadsFromProm( - r.Context(), client.Prom(), ns, buildPodOwnerLookup(ns))) + r.Context(), client.Prom(), ns, BuildPodOwnerLookup(ns))) } -// buildPodOwnerLookup snapshots radar's pod informer for `ns` so +// BuildPodOwnerLookup snapshots radar's pod informer for `ns` so // pkg/opencost.ComputeWorkloadsFromProm can resolve pod→workload without // depending on client-go. -func buildPodOwnerLookup(ns string) pkgopencost.PodOwnerLookup { +func BuildPodOwnerLookup(ns string) pkgopencost.PodOwnerLookup { rc := k8s.GetResourceCache() if rc == nil || rc.Pods() == nil { return nil diff --git a/internal/server/opencost_workload.go b/internal/server/opencost_workload.go new file mode 100644 index 000000000..58e95320d --- /dev/null +++ b/internal/server/opencost_workload.go @@ -0,0 +1,232 @@ +package server + +import ( + "fmt" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/skyhook-io/radar/internal/k8s" + internalopencost "github.com/skyhook-io/radar/internal/opencost" + prometheuspkg "github.com/skyhook-io/radar/internal/prometheus" + pkgopencost "github.com/skyhook-io/radar/pkg/opencost" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) { + if !s.requireConnected(w) { + return + } + + kind, namespace, name, ok := s.parseOpenCostWorkloadRequest(w, r) + if !ok { + return + } + _, desiredReplicas, ok := s.loadOpenCostWorkloadResource(w, kind, namespace, name) + if !ok { + return + } + + resp := &pkgopencost.WorkloadCostDetailResponse{ + Namespace: namespace, + Kind: kind, + Name: name, + } + + client := prometheuspkg.GetClient() + if client == nil { + resp.Available = false + resp.Reason = pkgopencost.ReasonNoPrometheus + s.writeJSON(w, resp) + return + } + if _, _, err := client.EnsureConnected(r.Context()); err != nil { + log.Printf("[opencost] EnsureConnected failed (workload %s %s/%s): %v", kind, namespace, name, err) + resp.Available = false + resp.Reason = pkgopencost.ReasonNoPrometheus + s.writeJSON(w, resp) + return + } + + workloads := pkgopencost.ComputeWorkloadsFromProm(r.Context(), client.Prom(), namespace, internalopencost.BuildPodOwnerLookup(namespace)) + s.writeJSON(w, focusOpenCostWorkload(workloads, kind, namespace, name, desiredReplicas)) +} + +func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Request) { + if !s.requireConnected(w) { + return + } + + kind, namespace, name, ok := s.parseOpenCostWorkloadRequest(w, r) + if !ok { + return + } + if _, _, ok := s.loadOpenCostWorkloadResource(w, kind, namespace, name); !ok { + return + } + + resp := &pkgopencost.WorkloadCostTrendResponse{ + Namespace: namespace, + Kind: kind, + Name: name, + Range: r.URL.Query().Get("range"), + } + + client := prometheuspkg.GetClient() + if client == nil { + resp.Available = false + resp.Reason = pkgopencost.ReasonNoPrometheus + s.writeJSON(w, resp) + return + } + if _, _, err := client.EnsureConnected(r.Context()); err != nil { + log.Printf("[opencost] EnsureConnected failed (workload trend %s %s/%s): %v", kind, namespace, name, err) + resp.Available = false + resp.Reason = pkgopencost.ReasonNoPrometheus + s.writeJSON(w, resp) + return + } + + s.writeJSON(w, pkgopencost.ComputeWorkloadCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.WorkloadTrendOptions{ + Range: r.URL.Query().Get("range"), + Namespace: namespace, + Kind: kind, + Name: name, + })) +} + +func (s *Server) parseOpenCostWorkloadRequest(w http.ResponseWriter, r *http.Request) (kind, namespace, name string, ok bool) { + kind, supported := pkgopencost.CanonicalWorkloadKind(chi.URLParam(r, "kind")) + if !supported { + s.writeError(w, http.StatusBadRequest, "only deployments, statefulsets, and daemonsets are supported") + return "", "", "", false + } + namespace = chi.URLParam(r, "namespace") + name = chi.URLParam(r, "name") + if namespace == "" || namespace == "_" || name == "" { + s.writeError(w, http.StatusBadRequest, "namespace and name are required") + return "", "", "", false + } + + if status, msg, ok := s.preflightResourceGet(r, normalizeKind(kind), namespace, name, "apps"); !ok { + s.writeError(w, status, msg) + return "", "", "", false + } + return kind, namespace, name, true +} + +func (s *Server) loadOpenCostWorkloadResource(w http.ResponseWriter, kind, namespace, name string) (any, int, bool) { + cache := k8s.GetResourceCache() + if cache == nil { + s.writeError(w, http.StatusServiceUnavailable, "Resource cache not available") + return nil, 0, false + } + + switch kind { + case "Deployment": + if cache.Deployments() == nil { + s.writeError(w, http.StatusForbidden, "insufficient permissions to access deployments") + return nil, 0, false + } + deploy, err := cache.Deployments().Deployments(namespace).Get(name) + if err != nil { + s.writeOpenCostWorkloadGetError(w, kind, namespace, name, err) + return nil, 0, false + } + replicas := int32(1) + if deploy.Spec.Replicas != nil { + replicas = *deploy.Spec.Replicas + } + return deploy, int(replicas), true + case "StatefulSet": + if cache.StatefulSets() == nil { + s.writeError(w, http.StatusForbidden, "insufficient permissions to access statefulsets") + return nil, 0, false + } + sts, err := cache.StatefulSets().StatefulSets(namespace).Get(name) + if err != nil { + s.writeOpenCostWorkloadGetError(w, kind, namespace, name, err) + return nil, 0, false + } + replicas := int32(1) + if sts.Spec.Replicas != nil { + replicas = *sts.Spec.Replicas + } + return sts, int(replicas), true + case "DaemonSet": + if cache.DaemonSets() == nil { + s.writeError(w, http.StatusForbidden, "insufficient permissions to access daemonsets") + return nil, 0, false + } + ds, err := cache.DaemonSets().DaemonSets(namespace).Get(name) + if err != nil { + s.writeOpenCostWorkloadGetError(w, kind, namespace, name, err) + return nil, 0, false + } + return ds, int(ds.Status.DesiredNumberScheduled), true + default: + s.writeError(w, http.StatusBadRequest, "only deployments, statefulsets, and daemonsets are supported") + return nil, 0, false + } +} + +func (s *Server) writeOpenCostWorkloadGetError(w http.ResponseWriter, kind, namespace, name string, err error) { + if apierrors.IsNotFound(err) { + s.writeError(w, http.StatusNotFound, fmt.Sprintf("%s %s/%s not found", kind, namespace, name)) + return + } + log.Printf("[opencost] Failed to get %s %s/%s: %v", kind, namespace, name, err) + s.writeError(w, http.StatusInternalServerError, "failed to get workload") +} + +func focusOpenCostWorkload(resp *pkgopencost.WorkloadCostResponse, kind, namespace, name string, desiredReplicas int) *pkgopencost.WorkloadCostDetailResponse { + out := &pkgopencost.WorkloadCostDetailResponse{ + Namespace: namespace, + Kind: kind, + Name: name, + } + if resp == nil { + out.Available = false + out.Reason = pkgopencost.ReasonQueryError + return out + } + if resp.Available { + for i := range resp.Workloads { + wl := resp.Workloads[i] + if wl.Kind == kind && wl.Name == name { + out.Available = true + out.Current = &wl + return out + } + } + if desiredReplicas == 0 { + out.Available = true + out.Current = zeroWorkloadCost(kind, name) + return out + } + out.Available = false + out.Reason = pkgopencost.ReasonNoMetrics + return out + } + if resp.Reason == pkgopencost.ReasonNoMetrics && desiredReplicas == 0 { + out.Available = true + out.Current = zeroWorkloadCost(kind, name) + return out + } + out.Available = false + out.Reason = resp.Reason + return out +} + +func zeroWorkloadCost(kind, name string) *pkgopencost.WorkloadCost { + return &pkgopencost.WorkloadCost{ + Name: name, + Kind: kind, + HourlyCost: 0, + CPUCost: 0, + MemoryCost: 0, + Replicas: 0, + Efficiency: 0, + IdleCost: 0, + } +} diff --git a/internal/server/opencost_workload_test.go b/internal/server/opencost_workload_test.go new file mode 100644 index 000000000..c2ed574f2 --- /dev/null +++ b/internal/server/opencost_workload_test.go @@ -0,0 +1,45 @@ +package server + +import ( + "testing" + + pkgopencost "github.com/skyhook-io/radar/pkg/opencost" +) + +func TestFocusOpenCostWorkloadScaledToZeroReturnsCurrentZero(t *testing.T) { + resp := focusOpenCostWorkload(&pkgopencost.WorkloadCostResponse{ + Available: false, + Reason: pkgopencost.ReasonNoMetrics, + Namespace: "default", + }, "Deployment", "default", "checkout", 0) + + if !resp.Available { + t.Fatalf("expected scaled-to-zero workload to be available, got %+v", resp) + } + if resp.Current == nil { + t.Fatalf("expected zero current row, got nil") + } + if resp.Current.Name != "checkout" || resp.Current.Kind != "Deployment" { + t.Fatalf("current row = %s/%s, want checkout/Deployment", resp.Current.Name, resp.Current.Kind) + } + if resp.Current.HourlyCost != 0 || resp.Current.Replicas != 0 { + t.Fatalf("current row should be zero cost and zero replicas, got %+v", resp.Current) + } +} + +func TestFocusOpenCostWorkloadMissingTargetWithReplicasReturnsNoMetrics(t *testing.T) { + resp := focusOpenCostWorkload(&pkgopencost.WorkloadCostResponse{ + Available: true, + Namespace: "default", + Workloads: []pkgopencost.WorkloadCost{ + {Name: "other", Kind: "Deployment", HourlyCost: 1, Replicas: 1}, + }, + }, "Deployment", "default", "checkout", 2) + + if resp.Available { + t.Fatalf("expected missing workload with desired replicas to be unavailable, got %+v", resp) + } + if resp.Reason != pkgopencost.ReasonNoMetrics { + t.Fatalf("Reason = %q, want %q", resp.Reason, pkgopencost.ReasonNoMetrics) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c1227c47f..6125d51a6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -510,6 +510,8 @@ func (s *Server) setupRoutes() { prometheuspkg.RegisterRoutes(r) // OpenCost routes + r.Get("/opencost/workload/{kind}/{namespace}/{name}", s.handleOpenCostWorkload) + r.Get("/opencost/workload/{kind}/{namespace}/{name}/trend", s.handleOpenCostWorkloadTrend) opencost.RegisterRoutes(r) // FluxCD routes diff --git a/packages/k8s-ui/src/components/workload/WorkloadView.tsx b/packages/k8s-ui/src/components/workload/WorkloadView.tsx index 7ce58b43a..44f69430e 100644 --- a/packages/k8s-ui/src/components/workload/WorkloadView.tsx +++ b/packages/k8s-ui/src/components/workload/WorkloadView.tsx @@ -29,6 +29,7 @@ import { X, BarChart3, Network, + DollarSign, } from 'lucide-react' import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types' import type { GitOpsStatus } from '../../types/gitops' @@ -77,7 +78,7 @@ import { } from '../resources/resource-utils' import { ServicePortCards, type ServicePortRenderProps } from '../resources/renderers/ServiceRenderer' -export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml' +export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'cost' | 'yaml' type TabType = WorkloadTabType export interface ResourceOwnershipContext { @@ -260,6 +261,8 @@ interface WorkloadViewProps { }) => ReactNode /** Render the metrics tab content */ renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode + /** Render the cost tab content */ + renderCostTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode /** Render a read-only YAML view for a related object from the workload's * neighborhood. Providing this turns the YAML tab into an object explorer * (rail of the workload + its Services/config/policies/pods); omitting it @@ -268,6 +271,8 @@ interface WorkloadViewProps { renderRelatedYaml?: (ref: { kind: string; namespace: string; name: string; group?: string }) => ReactNode /** Whether metrics are available for this resource kind */ isMetricsAvailable?: (kind: string, resource: any) => boolean + /** Whether cost is available for this resource kind */ + isCostAvailable?: (kind: string, resource: any) => boolean /** Render extra content at the bottom of the overview tab (e.g. audit findings) */ renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode /** Render content at the TOP of the overview tab, above the renderer (e.g. live @@ -358,7 +363,9 @@ export function WorkloadView({ renderLogsTab, renderRelatedYaml, renderMetricsTab, + renderCostTab, isMetricsAvailable, + isCostAvailable, // Duplicate onDuplicate, onDownload, @@ -641,8 +648,10 @@ export function WorkloadView({ }) const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false + const showCostTab = isCostAvailable ? isCostAvailable(kind, resource) : false const logsTabVisible = Boolean(allPods.length > 0 && renderLogsTab) const metricsTabVisible = Boolean(showMetricsTab && renderMetricsTab) + const costTabVisible = Boolean(showCostTab && renderCostTab) const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading const logsFallbackReady = !renderLogsTab || (!logsTabVisible && !podEvidenceLoading) const requestedTab: TabType = activeTab @@ -657,6 +666,7 @@ export function WorkloadView({ }, { id: 'logs', label: 'Logs', icon: , hidden: !logsTabVisible }, { id: 'metrics', label: 'Metrics', icon: , hidden: !metricsTabVisible }, + { id: 'cost', label: 'Cost', icon: , hidden: !costTabVisible }, { id: 'yaml', label: 'YAML', icon: }, ] const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden) @@ -667,6 +677,7 @@ export function WorkloadView({ ( (requestedTab === 'topology' && topologyTabHidden) || (requestedTab === 'metrics' && (!renderMetricsTab || (!!resource && !resourceLoading && !showMetricsTab))) || + (requestedTab === 'cost' && (!renderCostTab || (!!resource && !resourceLoading && !showCostTab))) || (requestedTab === 'logs' && logsFallbackReady) ) useEffect(() => { @@ -1026,6 +1037,11 @@ export function WorkloadView({ {renderMetricsTab({ kind: resource?.kind || kind, namespace, name })} )} + {effectiveTab === 'cost' && renderCostTab && ( +
+ {renderCostTab({ kind: resource?.kind || kind, namespace, name })} +
+ )} {effectiveTab === 'yaml' && (
{renderRelatedYaml && yamlObjects.length > 1 && ( diff --git a/pkg/opencost/types.go b/pkg/opencost/types.go index 8c8d22b61..81f24b75a 100644 --- a/pkg/opencost/types.go +++ b/pkg/opencost/types.go @@ -27,7 +27,7 @@ type CostSummary struct { // rows — Kind disambiguates (empty = namespace). type NamespaceCost struct { Name string `json:"name"` - Kind string `json:"kind,omitempty"` // "namespace" (default if empty) | "controller" | "pod" + Kind string `json:"kind,omitempty"` // "namespace" (default if empty) | "controller" | "pod" Namespace string `json:"namespace,omitempty"` // populated for controller/pod rows HourlyCost float64 `json:"hourlyCost"` CPUCost float64 `json:"cpuCost"` @@ -48,6 +48,16 @@ type WorkloadCostResponse struct { Workloads []WorkloadCost `json:"workloads"` } +// WorkloadCostDetailResponse is the focused current-cost response for one workload. +type WorkloadCostDetailResponse struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + Namespace string `json:"namespace"` + Kind string `json:"kind"` + Name string `json:"name"` + Current *WorkloadCost `json:"current,omitempty"` +} + // WorkloadCost holds per-workload cost breakdown within a namespace. type WorkloadCost struct { Name string `json:"name"` @@ -70,6 +80,18 @@ type CostTrendResponse struct { Series []CostTrendSeries `json:"series,omitempty"` } +// WorkloadCostTrendResponse is the focused historical cost response for one workload. +type WorkloadCostTrendResponse struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + Namespace string `json:"namespace"` + Kind string `json:"kind"` + Name string `json:"name"` + Range string `json:"range"` + WindowTotalCost float64 `json:"windowTotalCost,omitempty"` + DataPoints []CostDataPoint `json:"dataPoints,omitempty"` +} + // CostTrendSeries holds cost data points for a single namespace. type CostTrendSeries struct { Namespace string `json:"namespace"` diff --git a/pkg/opencost/workload_trend.go b/pkg/opencost/workload_trend.go new file mode 100644 index 000000000..bbc93d216 --- /dev/null +++ b/pkg/opencost/workload_trend.go @@ -0,0 +1,156 @@ +package opencost + +import ( + "context" + "fmt" + "log" + "sort" + + "github.com/skyhook-io/radar/pkg/prom" +) + +type WorkloadTrendOptions struct { + Range string + Namespace string + Kind string + Name string +} + +func ComputeWorkloadCostTrendFromProm(ctx context.Context, client *prom.Client, opts WorkloadTrendOptions) *WorkloadCostTrendResponse { + kind, ok := CanonicalWorkloadKind(opts.Kind) + resp := &WorkloadCostTrendResponse{ + Namespace: opts.Namespace, + Kind: kind, + Name: opts.Name, + } + if client == nil { + resp.Available = false + resp.Reason = ReasonNoPrometheus + return resp + } + if opts.Namespace == "" || opts.Name == "" || !ok { + resp.Available = false + resp.Reason = ReasonQueryError + return resp + } + + start, end, step, label := resolveTrendRange(opts.Range) + resp.Range = label + + query := buildWorkloadTrendQuery(opts.Namespace, kind, opts.Name, false) + result, err := client.QueryRange(ctx, query, start, end, step) + if err != nil { + log.Printf("[opencost] workload trend range query failed for %s %s/%s (range=%s), trying opencost_container_* fallback: %v", kind, opts.Namespace, opts.Name, label, err) + result, err = client.QueryRange(ctx, buildWorkloadTrendQuery(opts.Namespace, kind, opts.Name, true), start, end, step) + if err != nil { + log.Printf("[opencost] workload trend fallback query failed for %s %s/%s (range=%s): %v", kind, opts.Namespace, opts.Name, label, err) + resp.Available = false + resp.Reason = ReasonQueryError + return resp + } + } + if result == nil || len(result.Series) == 0 { + resp.Available = false + resp.Reason = ReasonNoMetrics + return resp + } + + points := mergeCostSeries(result.Series) + if len(points) == 0 { + resp.Available = false + resp.Reason = ReasonNoMetrics + return resp + } + + resp.Available = true + resp.DataPoints = points + resp.WindowTotalCost = roundTo(integrateHourlyCost(points), 4) + return resp +} + +func buildWorkloadTrendQuery(namespace, kind, name string, fallback bool) string { + safeNS := prom.SanitizeLabelValue(namespace) + safeName := prom.SanitizeLabelValue(name) + podCost := workloadPodCostExpr(safeNS, fallback) + + if kind == "Deployment" { + return fmt.Sprintf(`sum( + (%s) + * on(namespace, pod) group_left(replicaset) + label_replace(kube_pod_owner{namespace="%s", owner_kind="ReplicaSet"}, "replicaset", "$1", "owner_name", "(.+)") + * on(namespace, replicaset) group_left() + kube_replicaset_owner{namespace="%s", owner_kind="Deployment", owner_name="%s"} +)`, podCost, safeNS, safeNS, safeName) + } + + return fmt.Sprintf(`sum( + (%s) + * on(namespace, pod) group_left(owner_kind, owner_name) + kube_pod_owner{namespace="%s", owner_kind="%s", owner_name="%s"} +)`, podCost, safeNS, kind, safeName) +} + +func workloadPodCostExpr(namespace string, fallback bool) string { + if fallback { + return fmt.Sprintf(`sum by (namespace, pod) ( + (label_replace(rate(opencost_container_cpu_cost_total{exported_namespace="%s"}[1h]), "namespace", "$1", "exported_namespace", "(.+)") + or rate(opencost_container_cpu_cost_total{namespace="%s", exported_namespace=""}[1h])) +) + sum by (namespace, pod) ( + (label_replace(rate(opencost_container_memory_cost_total{exported_namespace="%s"}[1h]), "namespace", "$1", "exported_namespace", "(.+)") + or rate(opencost_container_memory_cost_total{namespace="%s", exported_namespace=""}[1h])) +)`, namespace, namespace, namespace, namespace) + } + + return fmt.Sprintf(`sum by (namespace, pod) ( + (label_replace(avg_over_time(container_cpu_allocation{exported_namespace="%s"}[1h]), "namespace", "$1", "exported_namespace", "(.+)") + or avg_over_time(container_cpu_allocation{namespace="%s", exported_namespace=""}[1h])) + * on(node) group_left() node_cpu_hourly_cost +) + sum by (namespace, pod) ( + (label_replace(avg_over_time(container_memory_allocation_bytes{exported_namespace="%s"}[1h]), "namespace", "$1", "exported_namespace", "(.+)") + or avg_over_time(container_memory_allocation_bytes{namespace="%s", exported_namespace=""}[1h])) + / 1073741824 * on(node) group_left() node_ram_hourly_cost +)`, namespace, namespace, namespace, namespace) +} + +func mergeCostSeries(series []prom.Series) []CostDataPoint { + byTS := make(map[int64]float64) + for _, s := range series { + for _, dp := range s.DataPoints { + byTS[dp.Timestamp] += dp.Value + } + } + points := make([]CostDataPoint, 0, len(byTS)) + for ts, val := range byTS { + points = append(points, CostDataPoint{Timestamp: ts, Value: roundTo(val, 4)}) + } + sort.Slice(points, func(i, j int) bool { return points[i].Timestamp < points[j].Timestamp }) + return points +} + +func integrateHourlyCost(points []CostDataPoint) float64 { + if len(points) < 2 { + return 0 + } + var total float64 + for i := 1; i < len(points); i++ { + deltaSeconds := points[i].Timestamp - points[i-1].Timestamp + if deltaSeconds <= 0 { + continue + } + total += points[i].Value * (float64(deltaSeconds) / 3600) + } + return total +} + +func CanonicalWorkloadKind(kind string) (string, bool) { + switch kind { + case "Deployment", "deployment", "deployments": + return "Deployment", true + case "StatefulSet", "statefulset", "statefulsets": + return "StatefulSet", true + case "DaemonSet", "daemonset", "daemonsets": + return "DaemonSet", true + default: + return "", false + } +} diff --git a/pkg/opencost/workload_trend_test.go b/pkg/opencost/workload_trend_test.go new file mode 100644 index 000000000..30ba706ff --- /dev/null +++ b/pkg/opencost/workload_trend_test.go @@ -0,0 +1,98 @@ +package opencost + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyhook-io/radar/pkg/prom" +) + +func TestComputeWorkloadCostTrendFromProm_DeploymentUsesOwnerMetrics(t *testing.T) { + var query string + client := workloadTrendProm(t, func(q string) string { + query = q + return matrixBody([]namespaceSeries{ + {"", []dpoint{{1700000000, 2}, {1700003600, 3}}}, + }) + }) + + got := ComputeWorkloadCostTrendFromProm(context.Background(), client, WorkloadTrendOptions{ + Range: "24h", + Namespace: "default", + Kind: "Deployment", + Name: "checkout", + }) + if !got.Available { + t.Fatalf("expected Available=true, got %+v", got) + } + if !strings.Contains(query, `kube_pod_owner{namespace="default", owner_kind="ReplicaSet"}`) { + t.Fatalf("deployment query does not join pod owners through ReplicaSets:\n%s", query) + } + if !strings.Contains(query, `kube_replicaset_owner{namespace="default", owner_kind="Deployment", owner_name="checkout"}`) { + t.Fatalf("deployment query does not join ReplicaSets to target Deployment:\n%s", query) + } + if strings.Contains(query, "kube_pod_labels") { + t.Fatalf("deployment query must not depend on kube_pod_labels allowlists:\n%s", query) + } + if got.WindowTotalCost != 3 { + t.Fatalf("WindowTotalCost = %v, want 3", got.WindowTotalCost) + } +} + +func TestComputeWorkloadCostTrendFromProm_StatefulSetUsesDirectPodOwner(t *testing.T) { + var query string + client := workloadTrendProm(t, func(q string) string { + query = q + return matrixBody([]namespaceSeries{ + {"", []dpoint{{1700000000, 1}, {1700003600, 1}}}, + }) + }) + + got := ComputeWorkloadCostTrendFromProm(context.Background(), client, WorkloadTrendOptions{ + Range: "6h", + Namespace: "db", + Kind: "StatefulSet", + Name: "postgres", + }) + if !got.Available { + t.Fatalf("expected Available=true, got %+v", got) + } + if !strings.Contains(query, `kube_pod_owner{namespace="db", owner_kind="StatefulSet", owner_name="postgres"}`) { + t.Fatalf("statefulset query does not use direct pod owner:\n%s", query) + } + if strings.Contains(query, "kube_replicaset_owner") { + t.Fatalf("statefulset query should not use ReplicaSet owner join:\n%s", query) + } +} + +func TestComputeWorkloadCostTrendFromProm_NoSeriesReturnsNoMetrics(t *testing.T) { + client := workloadTrendProm(t, func(string) string { + return `{"status":"success","data":{"resultType":"matrix","result":[]}}` + }) + + got := ComputeWorkloadCostTrendFromProm(context.Background(), client, WorkloadTrendOptions{ + Range: "7d", + Namespace: "default", + Kind: "DaemonSet", + Name: "agent", + }) + if got.Available { + t.Fatalf("expected unavailable response, got %+v", got) + } + if got.Reason != ReasonNoMetrics { + t.Fatalf("Reason = %q, want %q", got.Reason, ReasonNoMetrics) + } +} + +func workloadTrendProm(t *testing.T, bodyForQuery func(string) string) *prom.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(bodyForQuery(r.URL.Query().Get("query")))) + })) + t.Cleanup(srv.Close) + return prom.NewClient(prom.NewHTTPTransport(srv.URL, "", nil)) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2c491caca..675bffc09 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -604,6 +604,26 @@ export function useOpenCostWorkloads(namespace: string, options?: { enabled?: bo }) } +export interface OpenCostWorkloadDetailResponse { + available: boolean + reason?: CostUnavailableReason + namespace: string + kind: string + name: string + current?: OpenCostWorkloadCost +} + +export function useOpenCostWorkload(kind: string, namespace: string, name: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ['opencost-workload', kind, namespace, name], + queryFn: () => fetchJSON(`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`), + enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name), + staleTime: 30000, + refetchInterval: COST_REFRESH_INTERVAL_MS, + placeholderData: (prev) => prev, + }) +} + // Cost trend over time export type CostTimeRange = '6h' | '24h' | '7d' @@ -634,6 +654,28 @@ export function useOpenCostTrend(range_: CostTimeRange = '24h') { }) } +export interface OpenCostWorkloadTrendResponse { + available: boolean + reason?: CostUnavailableReason + namespace: string + kind: string + name: string + range: string + windowTotalCost?: number + dataPoints?: OpenCostTrendDataPoint[] +} + +export function useOpenCostWorkloadTrend(kind: string, namespace: string, name: string, range_: CostTimeRange = '24h', options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ['opencost-workload-trend', kind, namespace, name, range_], + queryFn: () => fetchJSON(`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`), + enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name), + staleTime: 60000, + refetchInterval: 120000, + placeholderData: (prev) => prev, + }) +} + // Node cost breakdown export interface OpenCostNodeCost { name: string diff --git a/web/src/components/cost/WorkloadCostTab.test.ts b/web/src/components/cost/WorkloadCostTab.test.ts new file mode 100644 index 000000000..bf1467108 --- /dev/null +++ b/web/src/components/cost/WorkloadCostTab.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { buildLineChart, getWorkloadCostState } from './WorkloadCostTab' +import type { OpenCostWorkloadDetailResponse, OpenCostWorkloadTrendResponse } from '../../api/client' + +describe('getWorkloadCostState', () => { + it('treats scaled-to-zero as a valid zero state', () => { + const current: OpenCostWorkloadDetailResponse = { + available: true, + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + current: { + name: 'checkout', + kind: 'Deployment', + hourlyCost: 0, + cpuCost: 0, + memoryCost: 0, + replicas: 0, + }, + } + const trend: OpenCostWorkloadTrendResponse = { + available: false, + reason: 'no_metrics', + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + range: '24h', + } + + expect(getWorkloadCostState(current, trend, false)).toBe('zero') + }) + + it('keeps current cost visible when only historical owner metrics are missing', () => { + const current: OpenCostWorkloadDetailResponse = { + available: true, + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + current: { + name: 'checkout', + kind: 'Deployment', + hourlyCost: 0.2, + cpuCost: 0.12, + memoryCost: 0.08, + replicas: 2, + }, + } + const trend: OpenCostWorkloadTrendResponse = { + available: false, + reason: 'no_metrics', + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + range: '24h', + } + + expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history') + }) + + it('uses historical data when current metrics are absent but history exists', () => { + const current: OpenCostWorkloadDetailResponse = { + available: false, + reason: 'no_metrics', + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + } + const trend: OpenCostWorkloadTrendResponse = { + available: true, + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + range: '7d', + dataPoints: [ + { timestamp: 1700000000, value: 0.1 }, + { timestamp: 1700003600, value: 0.2 }, + ], + } + + expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_current') + }) + + it('separates load failures from absent workload metrics', () => { + expect(getWorkloadCostState(undefined, undefined, { currentError: true })).toBe('load_error') + }) +}) + +describe('buildLineChart', () => { + it('returns stable paths for a non-empty workload series', () => { + const chart = buildLineChart([ + { timestamp: 1700000000, value: 1 }, + { timestamp: 1700003600, value: 2 }, + { timestamp: 1700007200, value: 1 }, + ]) + + expect(chart?.linePath).toContain('M 44.0') + expect(chart?.linePath).toContain('L 374.0') + expect(chart?.areaPath.endsWith('Z')).toBe(true) + expect(chart?.yTicks).toHaveLength(3) + }) +}) diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx new file mode 100644 index 000000000..fc2744825 --- /dev/null +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -0,0 +1,346 @@ +import { useMemo, useState } from 'react' +import { clsx } from 'clsx' +import { AlertCircle, DollarSign, Loader2, TrendingUp } from 'lucide-react' +import { + useOpenCostWorkload, + useOpenCostWorkloadTrend, + type CostTimeRange, + type CostUnavailableReason, + type OpenCostTrendDataPoint, + type OpenCostWorkloadDetailResponse, + type OpenCostWorkloadTrendResponse, +} from '../../api/client' + +const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ + { value: '6h', label: '6h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, +] + +type WorkloadCostState = 'loading' | 'data' | 'partial_missing_history' | 'partial_missing_current' | 'zero' | 'load_error' | CostUnavailableReason + +interface WorkloadCostQueryStatus { + currentLoading?: boolean + trendLoading?: boolean + currentError?: boolean + trendError?: boolean +} + +interface WorkloadCostTabProps { + kind: string + namespace: string + name: string +} + +export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) { + const [range, setRange] = useState('24h') + const currentQuery = useOpenCostWorkload(kind, namespace, name) + const trendQuery = useOpenCostWorkloadTrend(kind, namespace, name, range) + + const state = getWorkloadCostState(currentQuery.data, trendQuery.data, { + currentLoading: currentQuery.isLoading, + trendLoading: trendQuery.isLoading, + currentError: currentQuery.isError, + trendError: trendQuery.isError, + }) + + if (state === 'loading') { + return ( +
+ + Loading workload cost… +
+ ) + } + + if (state === 'no_prometheus' || state === 'no_metrics' || state === 'query_error' || state === 'load_error') { + return + } + + const current = currentQuery.data?.current + const trend = trendQuery.data + const points = trend?.available ? trend.dataPoints ?? [] : [] + const hasTrend = points.length >= 2 && points.some((p) => p.value > 0) + const hasCurrent = Boolean(current) + const trendLoading = trendQuery.isLoading && !trend + const hourly = current?.hourlyCost ?? 0 + const monthly = hourly * 730 + const windowTotal = trend?.available ? trend.windowTotalCost ?? 0 : 0 + const cpuCost = current?.cpuCost ?? 0 + const memoryCost = current?.memoryCost ?? 0 + const splitTotal = cpuCost + memoryCost + const cpuPct = splitTotal > 0 ? (cpuCost / splitTotal) * 100 : 0 + const memoryPct = splitTotal > 0 ? (memoryCost / splitTotal) * 100 : 0 + const windowSpendValue = hasTrend + ? `~${formatCost(windowTotal)}` + : trendLoading || state === 'partial_missing_history' + ? '—' + : formatCost(0) + + return ( +
+
+
+
+ +
+
Historical compute cost
+
CPU and memory allocation attributed by workload ownership
+
+
+
+ {TIME_RANGES.map((tr) => ( + + ))} +
+
+ +
+
+ + +
+
+ {trendLoading ? ( +
+ + Loading historical cost… +
+ ) : hasTrend ? ( + + ) : ( +
+ No historical workload owner cost points for this range. +
+ )} +
+
+
+ + {state === 'partial_missing_history' && ( +
+ + Current cost is available, but historical workload owner metrics are not available for this range. +
+ )} + {state === 'partial_missing_current' && ( +
+ + Historical cost is available, but current workload allocation metrics are not available. +
+ )} + +
+ + + +
+ +
+
+
+
Current cost split
+
Last 1h OpenCost allocation window
+
+
{hasCurrent ? `${formatCost(splitTotal)}/hr` : '—'}
+
+
+
+ {hasCurrent && ( + <> +
+
+ + )} +
+
+
+ + +
+
+ +
+ Powered by OpenCost via Prometheus. Workload cost currently includes CPU and memory allocation; storage/PVC attribution remains at namespace and cluster level. +
+
+ ) +} + +export function getWorkloadCostState( + current: OpenCostWorkloadDetailResponse | undefined, + trend: OpenCostWorkloadTrendResponse | undefined, + status: boolean | WorkloadCostQueryStatus, +): WorkloadCostState { + const queryStatus: WorkloadCostQueryStatus = typeof status === 'boolean' + ? { currentLoading: status, trendLoading: status } + : status + const loading = Boolean(queryStatus.currentLoading || queryStatus.trendLoading) + const queryError = Boolean(queryStatus.currentError || queryStatus.trendError) + + const currentRow = current?.available ? current.current : undefined + const trendHasData = trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0) + if (currentRow) { + if (queryStatus.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) return 'partial_missing_history' + if (currentRow.hourlyCost === 0 && currentRow.replicas === 0 && !trendHasData) return 'zero' + if (!trend?.available) return 'partial_missing_history' + return 'data' + } + if (trendHasData) return 'partial_missing_current' + if (queryError) return 'load_error' + if (loading) return 'loading' + + const reason = current?.reason ?? trend?.reason + if (reason === 'no_prometheus' || reason === 'query_error') return reason + return 'no_metrics' +} + +function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'load_error' }) { + const message = state === 'no_prometheus' + ? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.' + : state === 'query_error' + ? 'Cost data is temporarily unavailable. Prometheus was found, but workload cost queries failed.' + : state === 'load_error' + ? 'Could not load workload cost data. Check access to this workload and try again.' + : 'OpenCost workload metrics were not found for this workload.' + + return ( +
+
+ +
{message}
+
+
+ ) +} + +function MetricBlock({ label, value, subvalue }: { label: string; value: string; subvalue?: string }) { + return ( +
+
{label}
+
{value}
+ {subvalue &&
{subvalue}
} +
+ ) +} + +function MetricTile({ label, value, subvalue }: { label: string; value: string; subvalue?: string }) { + return ( +
+
{label}
+
{value}
+ {subvalue &&
{subvalue}
} +
+ ) +} + +function LegendItem({ colorClass, label, value }: { colorClass: string; label: string; value: string }) { + return ( +
+ + + {label} + + {value} +
+ ) +} + +function WorkloadCostLineChart({ points }: { points: OpenCostTrendDataPoint[] }) { + const chart = useMemo(() => buildLineChart(points), [points]) + if (!chart) return null + + return ( +
+ + {chart.yTicks.map((tick) => ( + + + + {formatCost(tick.value)} + + + ))} + + + + {formatChartTime(points[0]?.timestamp)} + + + {formatChartTime(points[points.length - 1]?.timestamp)} + + +
+ ) +} + +export function buildLineChart(points: OpenCostTrendDataPoint[]) { + if (points.length < 2) return null + const width = 720 + const height = 240 + const left = 44 + const right = 16 + const top = 14 + const bottom = 28 + const plotWidth = width - left - right + const plotHeight = height - top - bottom + const minTs = points[0].timestamp + const maxTs = points[points.length - 1].timestamp + if (maxTs <= minTs) return null + const maxValue = Math.max(...points.map((p) => p.value), 0) + const yMax = maxValue > 0 ? maxValue * 1.15 : 1 + const toX = (ts: number) => left + ((ts - minTs) / (maxTs - minTs)) * plotWidth + const toY = (value: number) => top + plotHeight - (value / yMax) * plotHeight + const coords = points.map((p) => [toX(p.timestamp), toY(p.value)] as const) + const linePath = coords.map(([x, y], idx) => `${idx === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ') + const baseline = top + plotHeight + const areaPath = `${linePath} L ${coords[coords.length - 1][0].toFixed(1)} ${baseline.toFixed(1)} L ${coords[0][0].toFixed(1)} ${baseline.toFixed(1)} Z` + const yTicks = [0, 0.5, 1].map((pct) => ({ + value: yMax * pct, + y: top + plotHeight - pct * plotHeight, + })) + return { linePath, areaPath, yTicks } +} + +function formatCost(value: number) { + if (!Number.isFinite(value) || value <= 0) return '$0.00' + if (value < 0.01) return `$${value.toFixed(4)}` + if (value < 1) return `$${value.toFixed(3)}` + return `$${value.toFixed(2)}` +} + +function formatChartTime(timestamp?: number) { + if (!timestamp) return '' + return new Date(timestamp * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + }) +} diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index 6613a47c5..f3975de67 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -42,6 +42,7 @@ import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusC import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid' import { RestartEventLane } from '../resource/RestartChart' import { RightsizingStrip } from '../resource/RightsizingStrip' +import { WorkloadCostTab } from '../cost/WorkloadCostTab' import { useResourceAudit, useResourceIssues, useResources } from '../../api/client' import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui' import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' @@ -679,9 +680,13 @@ export function WorkloadView({ renderMetricsTab={({ kind, namespace: ns, name: n }) => ( )} + renderCostTab={({ kind, namespace: ns, name: n }) => ( + + )} isMetricsAvailable={(kind, res) => isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending') } + isCostAvailable={(kind) => isOpenCostWorkloadKind(kind)} onDuplicate={handleDuplicate} onDownload={desktopDownload} actionsBarProps={actionsBarProps} @@ -913,6 +918,10 @@ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean { const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet']) +function isOpenCostWorkloadKind(kind: string): boolean { + return WORKLOAD_LOG_KINDS.has(kind) +} + function LogsTabContent({ kind, apiKind, From e76d12c3fc7c198f24b0ccc72212df289a807de2 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 18:11:40 +0300 Subject: [PATCH 02/12] Fix workload cost trends with duplicate metrics --- internal/prometheus/client.go | 63 ++++++++++++++++++++++++++++- internal/prometheus/client_test.go | 32 +++++++++++++++ internal/prometheus/discovery.go | 3 ++ pkg/opencost/compute.go | 8 ++-- pkg/opencost/queries.go | 6 +++ pkg/opencost/trend_prom.go | 4 +- pkg/opencost/workload_trend.go | 16 +++++--- pkg/opencost/workload_trend_test.go | 9 +++++ pkg/opencost/workloads.go | 8 ++-- 9 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 pkg/opencost/queries.go diff --git a/internal/prometheus/client.go b/internal/prometheus/client.go index 18e4adc46..7e4dd03ee 100644 --- a/internal/prometheus/client.go +++ b/internal/prometheus/client.go @@ -22,7 +22,8 @@ import ( // with a pkg/prom.Client that performs the actual HTTP calls once an // endpoint has been discovered. type Client struct { - mu sync.RWMutex + mu sync.RWMutex + discoverMu sync.Mutex // Effective connection (populated after discover succeeds). baseURL string @@ -34,6 +35,8 @@ type Client struct { discoveryService *prom.ServiceInfo // discovered service info for port-forward manualURL string // --prometheus-url override headers map[string]string + lastDiscoverErr error + lastDiscoverAt time.Time // K8s clients for discovery k8sClient kubernetes.Interface @@ -47,6 +50,8 @@ type Client struct { mcpHTTPClient *http.Client } +const failedDiscoveryCacheTTL = 5 * time.Second + // Global client instance var ( globalClient *Client @@ -101,6 +106,8 @@ func SetManualURL(rawURL string) { globalClient.mu.Lock() defer globalClient.mu.Unlock() globalClient.manualURL = strings.TrimRight(rawURL, "/") + globalClient.lastDiscoverErr = nil + globalClient.lastDiscoverAt = time.Time{} } // SetHeaders sets HTTP headers attached to every Prometheus request on the @@ -118,6 +125,8 @@ func SetHeaders(h map[string]string) { // Drop the cached prom.Client so the next request rebuilds its transport // with the new headers. globalClient.prom = nil + globalClient.lastDiscoverErr = nil + globalClient.lastDiscoverAt = time.Time{} } func copyHeaders(h map[string]string) map[string]string { @@ -147,6 +156,8 @@ func Reset() { globalClient.prom = nil globalClient.discovered = false globalClient.discoveryService = nil + globalClient.lastDiscoverErr = nil + globalClient.lastDiscoverAt = time.Time{} globalClient.mu.Unlock() } } @@ -231,7 +242,55 @@ func (c *Client) EnsureConnected(ctx context.Context) (string, string, error) { } } - return c.discover(ctx) + c.discoverMu.Lock() + defer c.discoverMu.Unlock() + + if err := c.recentDiscoveryError(time.Now()); err != nil { + return "", "", err + } + + c.mu.RLock() + base = c.baseURL + bp = c.basePath + c.mu.RUnlock() + if base != "" { + if p := c.getPromClient(); p != nil { + ok, reason := p.Probe(ctx) + if ok { + return base, bp, nil + } + log.Printf("[prometheus] cached connection to %s failed probe (reason=%s), rediscovering", base, reason) + c.mu.Lock() + c.baseURL = "" + c.basePath = "" + c.prom = nil + c.discovered = false + c.mu.Unlock() + } + } + + base, bp, err := c.discover(ctx) + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + c.mu.Lock() + c.lastDiscoverErr = err + c.lastDiscoverAt = time.Now() + c.mu.Unlock() + } + return "", "", err + } + return base, bp, nil +} + +func (c *Client) recentDiscoveryError(now time.Time) error { + c.mu.RLock() + err := c.lastDiscoverErr + at := c.lastDiscoverAt + c.mu.RUnlock() + if err == nil || at.IsZero() || now.Sub(at) >= failedDiscoveryCacheTTL { + return nil + } + return err } // Prom returns the underlying pkg/prom.Client for callers that compose diff --git a/internal/prometheus/client_test.go b/internal/prometheus/client_test.go index 910644f0c..d6616ddd2 100644 --- a/internal/prometheus/client_test.go +++ b/internal/prometheus/client_test.go @@ -2,6 +2,7 @@ package prometheus import ( "context" + "errors" "net/http" "net/http/httptest" "sync/atomic" @@ -139,3 +140,34 @@ func TestHeadersNoneWhenUnset(t *testing.T) { t.Error("Authorization header sent when none configured") } } + +func TestEnsureConnectedReturnsRecentDiscoveryError(t *testing.T) { + wantErr := errors.New("cached discovery failure") + c := &Client{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + lastDiscoverErr: wantErr, + lastDiscoverAt: time.Now(), + } + + _, _, gotErr := c.EnsureConnected(context.Background()) + if !errors.Is(gotErr, wantErr) { + t.Fatalf("EnsureConnected error = %v, want cached error %v", gotErr, wantErr) + } +} + +func TestEnsureConnectedIgnoresExpiredDiscoveryError(t *testing.T) { + wantErr := errors.New("cached discovery failure") + c := &Client{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + lastDiscoverErr: wantErr, + lastDiscoverAt: time.Now().Add(-failedDiscoveryCacheTTL - time.Second), + } + + _, _, gotErr := c.EnsureConnected(context.Background()) + if errors.Is(gotErr, wantErr) { + t.Fatalf("EnsureConnected returned expired cached error: %v", gotErr) + } + if gotErr == nil || gotErr.Error() != "no Kubernetes client available for discovery" { + t.Fatalf("EnsureConnected error = %v, want fresh discovery error", gotErr) + } +} diff --git a/internal/prometheus/discovery.go b/internal/prometheus/discovery.go index 67b0901a3..348644a42 100644 --- a/internal/prometheus/discovery.go +++ b/internal/prometheus/discovery.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "strings" + "time" "github.com/skyhook-io/radar/internal/errorlog" "github.com/skyhook-io/radar/internal/portforward" @@ -155,5 +156,7 @@ func (c *Client) markConnected(addr, basePath string) { c.basePath = basePath c.prom = nil c.discovered = true + c.lastDiscoverErr = nil + c.lastDiscoverAt = time.Time{} c.mu.Unlock() } diff --git a/pkg/opencost/compute.go b/pkg/opencost/compute.go index aed17c234..8cd6bd8bb 100644 --- a/pkg/opencost/compute.go +++ b/pkg/opencost/compute.go @@ -350,7 +350,7 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S } cpuResult, err := client.Query(ctx, - `sum by (namespace) (label_replace(avg_over_time(container_cpu_allocation{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") * on(node) group_left() node_cpu_hourly_cost)`) + `sum by (namespace) (label_replace(avg_over_time(container_cpu_allocation{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") * on(node) group_left() `+nodeCPUHourlyCostExpr+`)`) if err != nil { log.Printf("[opencost] CPU allocation query failed, trying opencost_container_cpu_cost_total: %v", err) cpuResult, err = client.Query(ctx, @@ -362,7 +362,7 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S } memResult, err := client.Query(ctx, - `sum by (namespace) (label_replace(avg_over_time(container_memory_allocation_bytes{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") / 1073741824 * on(node) group_left() node_ram_hourly_cost)`) + `sum by (namespace) (label_replace(avg_over_time(container_memory_allocation_bytes{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") / 1073741824 * on(node) group_left() `+nodeRAMHourlyCostExpr+`)`) if err != nil { log.Printf("[opencost] memory allocation query failed, trying opencost_container_memory_cost_total: %v", err) memResult, err = client.Query(ctx, @@ -381,14 +381,14 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S // and zero out cleanly if the queries fail, but a silent failure here can // look identical to a low-utilization workload — so log when it happens. cpuUsageRes, cpuUsageErr := client.Query(ctx, - `sum by (namespace) (label_replace(rate(container_cpu_usage_seconds_total{container!="", namespace!=""}[1h]), "node", "$1", "instance", "(.+?)(?::\\d+)?$") * on(node) group_left() node_cpu_hourly_cost)`) + `sum by (namespace) (label_replace(rate(container_cpu_usage_seconds_total{container!="", namespace!=""}[1h]), "node", "$1", "instance", "(.+?)(?::\\d+)?$") * on(node) group_left() `+nodeCPUHourlyCostExpr+`)`) if cpuUsageErr != nil { log.Printf("[opencost] CPU usage query failed (efficiency will be 0 for affected rows): %v", cpuUsageErr) } cpuUsageMap := lastValuePerLabel(cpuUsageRes, cpuUsageErr, "namespace") memUsageRes, memUsageErr := client.Query(ctx, - `sum by (namespace) (label_replace(container_memory_working_set_bytes{container!="", namespace!=""}, "node", "$1", "instance", "(.+?)(?::\\d+)?$") / 1073741824 * on(node) group_left() node_ram_hourly_cost)`) + `sum by (namespace) (label_replace(container_memory_working_set_bytes{container!="", namespace!=""}, "node", "$1", "instance", "(.+?)(?::\\d+)?$") / 1073741824 * on(node) group_left() `+nodeRAMHourlyCostExpr+`)`) if memUsageErr != nil { log.Printf("[opencost] memory usage query failed (efficiency will be 0 for affected rows): %v", memUsageErr) } diff --git a/pkg/opencost/queries.go b/pkg/opencost/queries.go new file mode 100644 index 000000000..73ff59e21 --- /dev/null +++ b/pkg/opencost/queries.go @@ -0,0 +1,6 @@ +package opencost + +const ( + nodeCPUHourlyCostExpr = `max by (node) (node_cpu_hourly_cost)` + nodeRAMHourlyCostExpr = `max by (node) (node_ram_hourly_cost)` +) diff --git a/pkg/opencost/trend_prom.go b/pkg/opencost/trend_prom.go index f9f9a727e..c20e90154 100644 --- a/pkg/opencost/trend_prom.go +++ b/pkg/opencost/trend_prom.go @@ -40,9 +40,9 @@ func ComputeCostTrendFromProm(ctx context.Context, client *prom.Client, opts Tre } const query = `sum by (namespace) ( - label_replace(avg_over_time(container_cpu_allocation{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") * on(node) group_left() node_cpu_hourly_cost + label_replace(avg_over_time(container_cpu_allocation{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") * on(node) group_left() ` + nodeCPUHourlyCostExpr + ` ) + sum by (namespace) ( - label_replace(avg_over_time(container_memory_allocation_bytes{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") / 1073741824 * on(node) group_left() node_ram_hourly_cost + label_replace(avg_over_time(container_memory_allocation_bytes{namespace!=""}[1h]), "namespace", "$1", "exported_namespace", "(.+)") / 1073741824 * on(node) group_left() ` + nodeRAMHourlyCostExpr + ` )` result, err := client.QueryRange(ctx, query, start, end, step) diff --git a/pkg/opencost/workload_trend.go b/pkg/opencost/workload_trend.go index bbc93d216..cee5b4498 100644 --- a/pkg/opencost/workload_trend.go +++ b/pkg/opencost/workload_trend.go @@ -77,16 +77,22 @@ func buildWorkloadTrendQuery(namespace, kind, name string, fallback bool) string return fmt.Sprintf(`sum( (%s) * on(namespace, pod) group_left(replicaset) - label_replace(kube_pod_owner{namespace="%s", owner_kind="ReplicaSet"}, "replicaset", "$1", "owner_name", "(.+)") + max by (namespace, pod, replicaset) ( + label_replace(kube_pod_owner{namespace="%s", owner_kind="ReplicaSet"}, "replicaset", "$1", "owner_name", "(.+)") + ) * on(namespace, replicaset) group_left() - kube_replicaset_owner{namespace="%s", owner_kind="Deployment", owner_name="%s"} + max by (namespace, replicaset) ( + kube_replicaset_owner{namespace="%s", owner_kind="Deployment", owner_name="%s"} + ) )`, podCost, safeNS, safeNS, safeName) } return fmt.Sprintf(`sum( (%s) * on(namespace, pod) group_left(owner_kind, owner_name) - kube_pod_owner{namespace="%s", owner_kind="%s", owner_name="%s"} + max by (namespace, pod, owner_kind, owner_name) ( + kube_pod_owner{namespace="%s", owner_kind="%s", owner_name="%s"} + ) )`, podCost, safeNS, kind, safeName) } @@ -104,11 +110,11 @@ func workloadPodCostExpr(namespace string, fallback bool) string { return fmt.Sprintf(`sum by (namespace, pod) ( (label_replace(avg_over_time(container_cpu_allocation{exported_namespace="%s"}[1h]), "namespace", "$1", "exported_namespace", "(.+)") or avg_over_time(container_cpu_allocation{namespace="%s", exported_namespace=""}[1h])) - * on(node) group_left() node_cpu_hourly_cost + * on(node) group_left() `+nodeCPUHourlyCostExpr+` ) + sum by (namespace, pod) ( (label_replace(avg_over_time(container_memory_allocation_bytes{exported_namespace="%s"}[1h]), "namespace", "$1", "exported_namespace", "(.+)") or avg_over_time(container_memory_allocation_bytes{namespace="%s", exported_namespace=""}[1h])) - / 1073741824 * on(node) group_left() node_ram_hourly_cost + / 1073741824 * on(node) group_left() `+nodeRAMHourlyCostExpr+` )`, namespace, namespace, namespace, namespace) } diff --git a/pkg/opencost/workload_trend_test.go b/pkg/opencost/workload_trend_test.go index 30ba706ff..8f923dd91 100644 --- a/pkg/opencost/workload_trend_test.go +++ b/pkg/opencost/workload_trend_test.go @@ -34,6 +34,12 @@ func TestComputeWorkloadCostTrendFromProm_DeploymentUsesOwnerMetrics(t *testing. if !strings.Contains(query, `kube_replicaset_owner{namespace="default", owner_kind="Deployment", owner_name="checkout"}`) { t.Fatalf("deployment query does not join ReplicaSets to target Deployment:\n%s", query) } + if !strings.Contains(query, `max by (namespace, pod, replicaset)`) { + t.Fatalf("deployment query must dedupe duplicate pod owner series before joining:\n%s", query) + } + if !strings.Contains(query, `max by (namespace, replicaset)`) { + t.Fatalf("deployment query must dedupe duplicate replicaset owner series before joining:\n%s", query) + } if strings.Contains(query, "kube_pod_labels") { t.Fatalf("deployment query must not depend on kube_pod_labels allowlists:\n%s", query) } @@ -63,6 +69,9 @@ func TestComputeWorkloadCostTrendFromProm_StatefulSetUsesDirectPodOwner(t *testi if !strings.Contains(query, `kube_pod_owner{namespace="db", owner_kind="StatefulSet", owner_name="postgres"}`) { t.Fatalf("statefulset query does not use direct pod owner:\n%s", query) } + if !strings.Contains(query, `max by (namespace, pod, owner_kind, owner_name)`) { + t.Fatalf("statefulset query must dedupe duplicate pod owner series before joining:\n%s", query) + } if strings.Contains(query, "kube_replicaset_owner") { t.Fatalf("statefulset query should not use ReplicaSet owner join:\n%s", query) } diff --git a/pkg/opencost/workloads.go b/pkg/opencost/workloads.go index e94cee8fe..fe1279a5d 100644 --- a/pkg/opencost/workloads.go +++ b/pkg/opencost/workloads.go @@ -40,7 +40,7 @@ func ComputeWorkloadsFromProm(ctx context.Context, client *prom.Client, namespac safeNS := prom.SanitizeLabelValue(namespace) cpuResult, err := client.Query(ctx, - `sum by (pod) ((avg_over_time(container_cpu_allocation{exported_namespace="`+safeNS+`"}[1h]) or avg_over_time(container_cpu_allocation{namespace="`+safeNS+`", exported_namespace=""}[1h])) * on(node) group_left() node_cpu_hourly_cost)`) + `sum by (pod) ((avg_over_time(container_cpu_allocation{exported_namespace="`+safeNS+`"}[1h]) or avg_over_time(container_cpu_allocation{namespace="`+safeNS+`", exported_namespace=""}[1h])) * on(node) group_left() `+nodeCPUHourlyCostExpr+`)`) if err != nil { log.Printf("[opencost] workloads CPU query failed for ns=%q, trying opencost_container_cpu_cost_total: %v", namespace, err) cpuResult, err = client.Query(ctx, @@ -52,7 +52,7 @@ func ComputeWorkloadsFromProm(ctx context.Context, client *prom.Client, namespac } memResult, err := client.Query(ctx, - `sum by (pod) ((avg_over_time(container_memory_allocation_bytes{exported_namespace="`+safeNS+`"}[1h]) or avg_over_time(container_memory_allocation_bytes{namespace="`+safeNS+`", exported_namespace=""}[1h])) / 1073741824 * on(node) group_left() node_ram_hourly_cost)`) + `sum by (pod) ((avg_over_time(container_memory_allocation_bytes{exported_namespace="`+safeNS+`"}[1h]) or avg_over_time(container_memory_allocation_bytes{namespace="`+safeNS+`", exported_namespace=""}[1h])) / 1073741824 * on(node) group_left() `+nodeRAMHourlyCostExpr+`)`) if err != nil { log.Printf("[opencost] workloads memory query failed for ns=%q, trying opencost_container_memory_cost_total: %v", namespace, err) memResult, err = client.Query(ctx, @@ -64,12 +64,12 @@ func ComputeWorkloadsFromProm(ctx context.Context, client *prom.Client, namespac } cpuUsageResult, cpuUsageErr := client.Query(ctx, - `sum by (pod) (label_replace(rate(container_cpu_usage_seconds_total{container!="", namespace="`+safeNS+`"}[1h]), "node", "$1", "instance", "(.+?)(?::\\d+)?$") * on(node) group_left() node_cpu_hourly_cost)`) + `sum by (pod) (label_replace(rate(container_cpu_usage_seconds_total{container!="", namespace="`+safeNS+`"}[1h]), "node", "$1", "instance", "(.+?)(?::\\d+)?$") * on(node) group_left() `+nodeCPUHourlyCostExpr+`)`) if cpuUsageErr != nil { log.Printf("[opencost] workloads CPU usage query failed for ns=%q (efficiency will be 0): %v", namespace, cpuUsageErr) } memUsageResult, memUsageErr := client.Query(ctx, - `sum by (pod) (label_replace(container_memory_working_set_bytes{container!="", namespace="`+safeNS+`"}, "node", "$1", "instance", "(.+?)(?::\\d+)?$") / 1073741824 * on(node) group_left() node_ram_hourly_cost)`) + `sum by (pod) (label_replace(container_memory_working_set_bytes{container!="", namespace="`+safeNS+`"}, "node", "$1", "instance", "(.+?)(?::\\d+)?$") / 1073741824 * on(node) group_left() `+nodeRAMHourlyCostExpr+`)`) if memUsageErr != nil { log.Printf("[opencost] workloads memory usage query failed for ns=%q (efficiency will be 0): %v", namespace, memUsageErr) } From a62bc0b643ecf46c44adc73613df738383b595ce Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 19:08:15 +0300 Subject: [PATCH 03/12] Improve OpenCost discovery UX --- internal/prometheus/client.go | 6 ++ internal/prometheus/client_test.go | 25 ++++++++ pkg/opencost/compute.go | 4 +- pkg/opencost/compute_test.go | 44 +++++++++++++ pkg/opencost/nodes.go | 46 ++++++++++++-- pkg/opencost/nodes_test.go | 68 +++++++++++++++++++++ pkg/opencost/queries.go | 8 ++- web/src/api/client.ts | 38 ++++++++++-- web/src/components/cost/CostTrendChart.tsx | 10 +-- web/src/components/cost/CostView.tsx | 66 +++++++++++++++++--- web/src/components/cost/WorkloadCostTab.tsx | 53 +++++++++++++++- web/src/components/cost/format.ts | 9 +++ 12 files changed, 344 insertions(+), 33 deletions(-) create mode 100644 pkg/opencost/nodes_test.go create mode 100644 web/src/components/cost/format.ts diff --git a/internal/prometheus/client.go b/internal/prometheus/client.go index 7e4dd03ee..6feef3018 100644 --- a/internal/prometheus/client.go +++ b/internal/prometheus/client.go @@ -232,6 +232,9 @@ func (c *Client) EnsureConnected(ctx context.Context) (string, string, error) { if ok { return base, bp, nil } + if err := ctx.Err(); err != nil { + return "", "", err + } log.Printf("[prometheus] cached connection to %s failed probe (reason=%s), rediscovering", base, reason) c.mu.Lock() c.baseURL = "" @@ -259,6 +262,9 @@ func (c *Client) EnsureConnected(ctx context.Context) (string, string, error) { if ok { return base, bp, nil } + if err := ctx.Err(); err != nil { + return "", "", err + } log.Printf("[prometheus] cached connection to %s failed probe (reason=%s), rediscovering", base, reason) c.mu.Lock() c.baseURL = "" diff --git a/internal/prometheus/client_test.go b/internal/prometheus/client_test.go index d6616ddd2..1eeb32c82 100644 --- a/internal/prometheus/client_test.go +++ b/internal/prometheus/client_test.go @@ -171,3 +171,28 @@ func TestEnsureConnectedIgnoresExpiredDiscoveryError(t *testing.T) { t.Fatalf("EnsureConnected error = %v, want fresh discovery error", gotErr) } } + +func TestEnsureConnectedDoesNotClearCachedConnectionOnCanceledProbe(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer srv.Close() + + c := &Client{ + baseURL: srv.URL, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, gotErr := c.EnsureConnected(ctx) + if !errors.Is(gotErr, context.Canceled) { + t.Fatalf("EnsureConnected error = %v, want context.Canceled", gotErr) + } + c.mu.RLock() + gotBase := c.baseURL + c.mu.RUnlock() + if gotBase != srv.URL { + t.Fatalf("baseURL = %q, want cached connection preserved %q", gotBase, srv.URL) + } +} diff --git a/pkg/opencost/compute.go b/pkg/opencost/compute.go index 8cd6bd8bb..6fee41fc9 100644 --- a/pkg/opencost/compute.go +++ b/pkg/opencost/compute.go @@ -395,7 +395,7 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S memUsageMap := lastValuePerLabel(memUsageRes, memUsageErr, "namespace") storageRes, storageErr := client.Query(ctx, - `sum by (namespace) (pv_hourly_cost * on(persistentvolume) group_left(namespace) kube_persistentvolume_claim_ref)`) + `sum by (namespace) (`+persistentVolumeHourlyCostExpr+` * on(persistentvolume) group_left(namespace) `+persistentVolumeClaimRef+`)`) if storageErr != nil { log.Printf("[opencost] storage cost query failed (storage costs will be 0): %v", storageErr) } @@ -425,7 +425,7 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S namespaces = append(namespaces, *nc) } - if nodeResult, err := client.Query(ctx, `sum(node_total_hourly_cost)`); err == nil && len(nodeResult.Series) > 0 && len(nodeResult.Series[0].DataPoints) > 0 { + if nodeResult, err := client.Query(ctx, `sum(`+nodeTotalHourlyCostExpr+`)`); err == nil && len(nodeResult.Series) > 0 && len(nodeResult.Series[0].DataPoints) > 0 { if nodeCost := nodeResult.Series[0].DataPoints[0].Value; nodeCost > totalHourlyCost { totalHourlyCost = nodeCost } diff --git a/pkg/opencost/compute_test.go b/pkg/opencost/compute_test.go index ef658cef8..34843e06d 100644 --- a/pkg/opencost/compute_test.go +++ b/pkg/opencost/compute_test.go @@ -218,6 +218,50 @@ func TestComputeCostSummary_RoundsValues(t *testing.T) { } } +func TestComputeCostSummary_DedupesPersistentVolumeClaimRefs(t *testing.T) { + var queries []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("query") + queries = append(queries, q) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(q, "container_cpu_allocation"): + _, _ = w.Write([]byte(vectorBody(map[string]float64{"checkout": 1.0}))) + case strings.Contains(q, "container_memory_allocation_bytes"): + _, _ = w.Write([]byte(vectorBody(map[string]float64{"checkout": 1.0}))) + default: + _, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)) + } + })) + defer srv.Close() + client := prom.NewClient(prom.NewHTTPTransport(srv.URL, "", nil)) + + got := ComputeCostSummaryFromProm(context.Background(), client, SummaryOptions{}) + if !got.Available { + t.Fatalf("summary unavailable: %+v", got) + } + + var storageQuery string + for _, q := range queries { + if strings.Contains(q, "pv_hourly_cost") { + storageQuery = q + break + } + } + if storageQuery == "" { + t.Fatal("storage query was not issued") + } + if !strings.Contains(storageQuery, "max by (persistentvolume) (pv_hourly_cost)") { + t.Fatalf("storage query must dedupe duplicate PV cost series:\n%s", storageQuery) + } + if !strings.Contains(storageQuery, "max by (persistentvolume, namespace)") { + t.Fatalf("storage query must dedupe duplicate PVC ref series:\n%s", storageQuery) + } + if !strings.Contains(storageQuery, `label_replace(kube_persistentvolume_claim_ref, "namespace", "$1", "claim_namespace", "(.+)")`) { + t.Fatalf("storage query must normalize claim_namespace before namespace aggregation:\n%s", storageQuery) + } +} + func TestWindowHours(t *testing.T) { cases := []struct { in string diff --git a/pkg/opencost/nodes.go b/pkg/opencost/nodes.go index e3eda73d7..8771a5a1e 100644 --- a/pkg/opencost/nodes.go +++ b/pkg/opencost/nodes.go @@ -18,7 +18,7 @@ func ComputeNodeCosts(ctx context.Context, client *prom.Client) *NodeCostRespons return &NodeCostResponse{Available: false, Reason: ReasonNoPrometheus} } - totalResult, err := client.Query(ctx, `node_total_hourly_cost`) + totalResult, err := client.Query(ctx, nodeTotalHourlyCostExpr) if err != nil { log.Printf("[opencost] node_total_hourly_cost query failed: %v", err) return &NodeCostResponse{Available: false, Reason: ReasonQueryError} @@ -27,10 +27,12 @@ func ComputeNodeCosts(ctx context.Context, client *prom.Client) *NodeCostRespons return &NodeCostResponse{Available: false, Reason: ReasonNoMetrics} } - cpuResult, cpuErr := client.Query(ctx, `node_cpu_hourly_cost`) + cpuResult, cpuErr := client.Query(ctx, nodeCPUHourlyCostExpr) cpuMap := lastValuePerLabel(cpuResult, cpuErr, "node") - memResult, memErr := client.Query(ctx, `node_ram_hourly_cost`) + memResult, memErr := client.Query(ctx, nodeRAMHourlyCostExpr) memMap := lastValuePerLabel(memResult, memErr, "node") + metadataResult, metadataErr := client.Query(ctx, nodeTotalHourlyCostMetadataExpr) + metadataMap := nodeMetadataByNode(metadataResult, metadataErr) nodes := make([]NodeCost, 0, len(totalResult.Series)) for _, s := range totalResult.Series { @@ -38,10 +40,11 @@ func ComputeNodeCosts(ctx context.Context, client *prom.Client) *NodeCostRespons if node == "" || len(s.DataPoints) == 0 { continue } + metadata := metadataMap[node] nodes = append(nodes, NodeCost{ Name: node, - InstanceType: s.Labels["instance_type"], - Region: s.Labels["region"], + InstanceType: metadata.instanceType, + Region: metadata.region, HourlyCost: roundTo(s.DataPoints[len(s.DataPoints)-1].Value, 4), CPUCost: roundTo(cpuMap[node], 4), MemoryCost: roundTo(memMap[node], 4), @@ -53,6 +56,39 @@ func ComputeNodeCosts(ctx context.Context, client *prom.Client) *NodeCostRespons return &NodeCostResponse{Available: true, Nodes: nodes} } +type nodeMetadata struct { + instanceType string + region string +} + +func nodeMetadataByNode(result *prom.QueryResult, err error) map[string]nodeMetadata { + out := make(map[string]nodeMetadata) + if err != nil || result == nil { + return out + } + for _, s := range result.Series { + node := s.Labels["node"] + if node == "" || len(s.DataPoints) == 0 { + continue + } + current := out[node] + next := nodeMetadata{ + instanceType: s.Labels["instance_type"], + region: s.Labels["region"], + } + if current.instanceType == "" || current.region == "" { + if next.instanceType == "" { + next.instanceType = current.instanceType + } + if next.region == "" { + next.region = current.region + } + out[node] = next + } + } + return out +} + func lastValuePerLabel(result *prom.QueryResult, err error, label string) map[string]float64 { out := make(map[string]float64) if err != nil || result == nil { diff --git a/pkg/opencost/nodes_test.go b/pkg/opencost/nodes_test.go new file mode 100644 index 000000000..50e8db539 --- /dev/null +++ b/pkg/opencost/nodes_test.go @@ -0,0 +1,68 @@ +package opencost + +import ( + "context" + "encoding/json" + "testing" +) + +func TestComputeNodeCosts_DedupesTotalsByNodeAndKeepsMetadata(t *testing.T) { + client := scriptedProm(t, []scriptedCase{ + { + contains: `max by (node) (node_total_hourly_cost)`, + body: nodeVectorBody([]nodeSample{ + {labels: map[string]string{"node": "node-a"}, value: 0.42}, + }), + }, + { + contains: `max by (node, instance_type, region) (node_total_hourly_cost)`, + body: nodeVectorBody([]nodeSample{ + {labels: map[string]string{"node": "node-a", "instance_type": "e2-standard-4", "region": "us-east1"}, value: 0.42}, + {labels: map[string]string{"node": "node-a", "instance_type": "", "region": "us-east1"}, value: 0.41}, + }), + }, + }) + + got := ComputeNodeCosts(context.Background(), client) + if !got.Available { + t.Fatalf("node costs unavailable: %+v", got) + } + if len(got.Nodes) != 1 { + t.Fatalf("len(Nodes)=%d, want 1: %+v", len(got.Nodes), got.Nodes) + } + node := got.Nodes[0] + if node.Name != "node-a" || node.HourlyCost != 0.42 { + t.Fatalf("node cost mismatch: %+v", node) + } + if node.InstanceType != "e2-standard-4" || node.Region != "us-east1" { + t.Fatalf("node metadata mismatch: %+v", node) + } +} + +type nodeSample struct { + labels map[string]string + value float64 +} + +func nodeVectorBody(samples []nodeSample) string { + type result struct { + Metric map[string]string `json:"metric"` + Value []interface{} `json:"value"` + } + body := struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []result `json:"result"` + } `json:"data"` + }{Status: "success"} + body.Data.ResultType = "vector" + for _, sample := range samples { + body.Data.Result = append(body.Data.Result, result{ + Metric: sample.labels, + Value: []interface{}{1700000000.0, formatFloat(sample.value)}, + }) + } + b, _ := json.Marshal(body) + return string(b) +} diff --git a/pkg/opencost/queries.go b/pkg/opencost/queries.go index 73ff59e21..4fec174f0 100644 --- a/pkg/opencost/queries.go +++ b/pkg/opencost/queries.go @@ -1,6 +1,10 @@ package opencost const ( - nodeCPUHourlyCostExpr = `max by (node) (node_cpu_hourly_cost)` - nodeRAMHourlyCostExpr = `max by (node) (node_ram_hourly_cost)` + nodeTotalHourlyCostExpr = `max by (node) (node_total_hourly_cost)` + nodeTotalHourlyCostMetadataExpr = `max by (node, instance_type, region) (node_total_hourly_cost)` + nodeCPUHourlyCostExpr = `max by (node) (node_cpu_hourly_cost)` + nodeRAMHourlyCostExpr = `max by (node) (node_ram_hourly_cost)` + persistentVolumeHourlyCostExpr = `max by (persistentvolume) (pv_hourly_cost)` + persistentVolumeClaimRef = `max by (persistentvolume, namespace) (label_replace(kube_persistentvolume_claim_ref, "namespace", "$1", "claim_namespace", "(.+)"))` ) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 675bffc09..0169afca7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -42,6 +42,9 @@ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000 const AUDIT_REFRESH_INTERVAL_MS = 60_000 const ISSUES_REFRESH_INTERVAL_MS = 30_000 const COST_REFRESH_INTERVAL_MS = 60_000 +const COST_DISCOVERY_RETRY_INTERVAL_MS = 2_000 +export const COST_DISCOVERY_GRACE_MS = 30_000 +const COST_TREND_REFRESH_INTERVAL_MS = 120_000 const CHANGES_REFRESH_INTERVAL_MS = 60_000 const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000 @@ -564,11 +567,35 @@ export interface OpenCostSummary { namespaces?: OpenCostNamespaceCost[] } +const noPrometheusFirstSeenAt = new Map() + +function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTERVAL_MS) { + return (query: { + queryHash?: string + queryKey?: unknown + state: { + data?: { available?: boolean; reason?: CostUnavailableReason } + dataUpdatedAt?: number + } + }) => { + const data = query.state.data + const queryID = query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost') + if (data?.available === false && data.reason === 'no_prometheus') { + const now = Date.now() + const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? query.state.dataUpdatedAt ?? now + noPrometheusFirstSeenAt.set(queryID, firstSeenAt) + return now - firstSeenAt < COST_DISCOVERY_GRACE_MS ? COST_DISCOVERY_RETRY_INTERVAL_MS : defaultInterval + } + noPrometheusFirstSeenAt.delete(queryID) + return defaultInterval + } +} + export function useOpenCostSummary() { return useQuery({ queryKey: ['opencost-summary'], queryFn: () => fetchJSON('/opencost/summary'), - refetchInterval: COST_REFRESH_INTERVAL_MS, + refetchInterval: costRefetchInterval(), staleTime: 30000, placeholderData: (prev) => prev, // Keep previous data visible during refetch }) @@ -600,6 +627,7 @@ export function useOpenCostWorkloads(namespace: string, options?: { enabled?: bo queryKey: ['opencost-workloads', namespace], queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`), enabled: (options?.enabled ?? true) && Boolean(namespace), + refetchInterval: costRefetchInterval(false), staleTime: 30000, }) } @@ -619,7 +647,7 @@ export function useOpenCostWorkload(kind: string, namespace: string, name: strin queryFn: () => fetchJSON(`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`), enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name), staleTime: 30000, - refetchInterval: COST_REFRESH_INTERVAL_MS, + refetchInterval: costRefetchInterval(), placeholderData: (prev) => prev, }) } @@ -649,7 +677,7 @@ export function useOpenCostTrend(range_: CostTimeRange = '24h') { queryKey: ['opencost-trend', range_], queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`), staleTime: 60000, - refetchInterval: 120000, // Refresh every 2 minutes + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), placeholderData: (prev) => prev, }) } @@ -671,7 +699,7 @@ export function useOpenCostWorkloadTrend(kind: string, namespace: string, name: queryFn: () => fetchJSON(`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`), enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name), staleTime: 60000, - refetchInterval: 120000, + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), placeholderData: (prev) => prev, }) } @@ -697,7 +725,7 @@ export function useOpenCostNodes() { queryKey: ['opencost-nodes'], queryFn: () => fetchJSON('/opencost/nodes'), staleTime: 60000, - refetchInterval: 120000, + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), placeholderData: (prev) => prev, }) } diff --git a/web/src/components/cost/CostTrendChart.tsx b/web/src/components/cost/CostTrendChart.tsx index 664f6fb06..f40b44da6 100644 --- a/web/src/components/cost/CostTrendChart.tsx +++ b/web/src/components/cost/CostTrendChart.tsx @@ -6,6 +6,7 @@ import { type CostTimeRange, type OpenCostTrendSeries, } from '../../api/client' +import { formatCostAxis } from './format' const SERIES_COLORS = [ '#3b82f6', // blue-500 @@ -360,18 +361,11 @@ function ChartLegend({ series }: { series: OpenCostTrendSeries[] }) { ) } -function formatCostAxis(value: number): string { - if (value >= 1000) return `$${(value / 1000).toFixed(0)}k` - if (value >= 1) return `$${value.toFixed(1)}` - if (value >= 0.01) return `$${value.toFixed(2)}` - if (value > 0) return `$${value.toFixed(3)}` - return '$0' -} - function formatCostTooltip(value: number): string { if (value >= 1000) return `$${(value / 1000).toFixed(1)}k/hr` if (value >= 1) return `$${value.toFixed(2)}/hr` if (value >= 0.01) return `$${value.toFixed(3)}/hr` + if (value > 0 && value < 0.0001) return `${formatCostAxis(value)}/hr` if (value > 0) return `$${value.toFixed(4)}/hr` return '$0.00/hr' } diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx index 0e51116b8..2ec66a604 100644 --- a/web/src/components/cost/CostView.tsx +++ b/web/src/components/cost/CostView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client' +import { COST_DISCOVERY_GRACE_MS, useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client' import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client' import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react' import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui' @@ -12,10 +12,19 @@ interface CostViewProps { } export function CostView({ onBack }: CostViewProps) { - const { data, isLoading, dataUpdatedAt, refetch } = useOpenCostSummary() + const { data, isLoading, isFetching, dataUpdatedAt, refetch } = useOpenCostSummary() const { data: nodeData } = useOpenCostNodes() const { connection } = useConnection() const [showHelp, setShowHelp] = useState(false) + const [noPrometheusSince, setNoPrometheusSince] = useState(null) + + useEffect(() => { + if (data?.available === false && data.reason === 'no_prometheus') { + setNoPrometheusSince((prev) => prev ?? Date.now()) + } else { + setNoPrometheusSince(null) + } + }, [data?.available, data?.reason]) if (isLoading) { return @@ -23,13 +32,40 @@ export function CostView({ onBack }: CostViewProps) { if (!data || !data.available) { const reason = data?.reason - const message = reason === 'no_prometheus' - ? 'Prometheus not found — OpenCost requires Prometheus or VictoriaMetrics' - : reason === 'no_metrics' - ? 'OpenCost metrics not found — Prometheus is available but no cost metrics were detected' - : reason === 'query_error' - ? 'Cost data temporarily unavailable — Prometheus was found but queries failed' - : 'OpenCost not detected — install OpenCost for cost visibility' + const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince + if (reason === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) { + return ( +
+
+ +
+

Looking for Prometheus cost data…

+

+ First discovery can take a few seconds while Radar checks cluster services and opens a local port-forward. +

+
+ +
+
+ ) + } + const message = + reason === 'no_prometheus' + ? 'Prometheus not found — OpenCost requires Prometheus or VictoriaMetrics' + : reason === 'no_metrics' + ? 'OpenCost metrics not found — Prometheus is available but no cost metrics were detected' + : reason === 'query_error' + ? 'Cost data temporarily unavailable — Prometheus was found but queries failed' + : 'OpenCost not detected — install OpenCost for cost visibility' return (
@@ -42,6 +78,18 @@ export function CostView({ onBack }: CostViewProps) { > Back to Dashboard + {reason === 'no_prometheus' && ( + + )}
) diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index fc2744825..aa9befe4c 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -1,15 +1,17 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { clsx } from 'clsx' import { AlertCircle, DollarSign, Loader2, TrendingUp } from 'lucide-react' import { useOpenCostWorkload, useOpenCostWorkloadTrend, + COST_DISCOVERY_GRACE_MS, type CostTimeRange, type CostUnavailableReason, type OpenCostTrendDataPoint, type OpenCostWorkloadDetailResponse, type OpenCostWorkloadTrendResponse, } from '../../api/client' +import { formatCostAxis } from './format' const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ { value: '6h', label: '6h' }, @@ -34,6 +36,7 @@ interface WorkloadCostTabProps { export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) { const [range, setRange] = useState('24h') + const [noPrometheusSince, setNoPrometheusSince] = useState(null) const currentQuery = useOpenCostWorkload(kind, namespace, name) const trendQuery = useOpenCostWorkloadTrend(kind, namespace, name, range) @@ -44,6 +47,14 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) trendError: trendQuery.isError, }) + useEffect(() => { + if (state === 'no_prometheus') { + setNoPrometheusSince((prev) => prev ?? Date.now()) + } else { + setNoPrometheusSince(null) + } + }, [state]) + if (state === 'loading') { return (
@@ -54,6 +65,19 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) } if (state === 'no_prometheus' || state === 'no_metrics' || state === 'query_error' || state === 'load_error') { + const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince + if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) { + return ( + { + setNoPrometheusSince(Date.now()) + currentQuery.refetch() + trendQuery.refetch() + }} + /> + ) + } return } @@ -208,6 +232,7 @@ export function getWorkloadCostState( const currentRow = current?.available ? current.current : undefined const trendHasData = trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0) if (currentRow) { + if (queryStatus.trendLoading && !trend) return 'data' if (queryStatus.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) return 'partial_missing_history' if (currentRow.hourlyCost === 0 && currentRow.replicas === 0 && !trendHasData) return 'zero' if (!trend?.available) return 'partial_missing_history' @@ -222,6 +247,29 @@ export function getWorkloadCostState( return 'no_metrics' } +function WorkloadCostDiscovering({ isFetching, onRetry }: { isFetching: boolean; onRetry: () => void }) { + return ( +
+
+ +
+

Looking for Prometheus cost data…

+

+ First discovery can take a few seconds while Radar checks cluster services and opens a local port-forward. +

+
+ +
+
+ ) +} + function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'load_error' }) { const message = state === 'no_prometheus' ? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.' @@ -284,7 +332,7 @@ function WorkloadCostLineChart({ points }: { points: OpenCostTrendDataPoint[] }) - {formatCost(tick.value)} + {formatCostAxis(tick.value)} ))} @@ -331,6 +379,7 @@ export function buildLineChart(points: OpenCostTrendDataPoint[]) { function formatCost(value: number) { if (!Number.isFinite(value) || value <= 0) return '$0.00' + if (value < 0.0001) return formatCostAxis(value) if (value < 0.01) return `$${value.toFixed(4)}` if (value < 1) return `$${value.toFixed(3)}` return `$${value.toFixed(2)}` diff --git a/web/src/components/cost/format.ts b/web/src/components/cost/format.ts new file mode 100644 index 000000000..82dd6a42a --- /dev/null +++ b/web/src/components/cost/format.ts @@ -0,0 +1,9 @@ +export function formatCostAxis(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '$0' + if (value >= 1000) return `$${(value / 1000).toFixed(0)}k` + if (value >= 1) return `$${value.toFixed(1)}` + if (value >= 0.01) return `$${value.toFixed(2)}` + if (value >= 0.0001) return `$${value.toFixed(4)}` + if (value >= 0.00001) return `$${value.toFixed(5)}` + return '<$0.00001' +} From 9cf2aa5f36fae8edddb86083664c74c8a775a111 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 5 Jul 2026 19:40:14 +0300 Subject: [PATCH 04/12] Clarify workload cost metrics --- web/src/components/cost/WorkloadCostTab.tsx | 31 +++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index aa9befe4c..cb0ec6719 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { clsx } from 'clsx' -import { AlertCircle, DollarSign, Loader2, TrendingUp } from 'lucide-react' +import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react' import { useOpenCostWorkload, useOpenCostWorkloadTrend, @@ -11,6 +11,7 @@ import { type OpenCostWorkloadDetailResponse, type OpenCostWorkloadTrendResponse, } from '../../api/client' +import { Tooltip } from '../ui/Tooltip' import { formatCostAxis } from './format' const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ @@ -108,7 +109,12 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
-
Historical compute cost
+
+
Historical compute cost
+ +
CPU and memory allocation attributed by workload ownership
@@ -179,6 +185,7 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) label="Efficiency" value={hasCurrent ? (current?.efficiency ? `${current.efficiency.toFixed(0)}%` : '0%') : '—'} subvalue={hasCurrent ? `${formatCost(current?.idleCost ?? 0)}/hr idle` : 'Current allocation unavailable'} + tooltip="Actual CPU and memory usage divided by allocated CPU and memory cost for the last hour. Low efficiency usually means requested capacity is sitting idle." />
-
Current cost split
+
+
Current cost split
+ +
Last 1h OpenCost allocation window
{hasCurrent ? `${formatCost(splitTotal)}/hr` : '—'}
@@ -299,16 +309,27 @@ function MetricBlock({ label, value, subvalue }: { label: string; value: string; ) } -function MetricTile({ label, value, subvalue }: { label: string; value: string; subvalue?: string }) { +function MetricTile({ label, value, subvalue, tooltip }: { label: string; value: string; subvalue?: string; tooltip?: string }) { return (
-
{label}
+
+
{label}
+ {tooltip && } +
{value}
{subvalue &&
{subvalue}
}
) } +function MetricInfoTooltip({ content }: { content: string }) { + return ( + + + + ) +} + function LegendItem({ colorClass, label, value }: { colorClass: string; label: string; value: string }) { return (
From 8995715fa2ae1e9d00423f5b2147b8439c2a0af3 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Fri, 10 Jul 2026 02:46:09 +0300 Subject: [PATCH 05/12] Fix workload cost stale loading states --- web/src/api/client.ts | 2 +- .../components/cost/WorkloadCostTab.test.ts | 19 +++++++++++++++++++ web/src/components/cost/WorkloadCostTab.tsx | 10 ++++++---- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0169afca7..66735e680 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -582,7 +582,7 @@ function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTE const queryID = query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost') if (data?.available === false && data.reason === 'no_prometheus') { const now = Date.now() - const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? query.state.dataUpdatedAt ?? now + const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now noPrometheusFirstSeenAt.set(queryID, firstSeenAt) return now - firstSeenAt < COST_DISCOVERY_GRACE_MS ? COST_DISCOVERY_RETRY_INTERVAL_MS : defaultInterval } diff --git a/web/src/components/cost/WorkloadCostTab.test.ts b/web/src/components/cost/WorkloadCostTab.test.ts index bf1467108..834c2b78e 100644 --- a/web/src/components/cost/WorkloadCostTab.test.ts +++ b/web/src/components/cost/WorkloadCostTab.test.ts @@ -57,6 +57,25 @@ describe('getWorkloadCostState', () => { expect(getWorkloadCostState(current, trend, false)).toBe('partial_missing_history') }) + it('keeps current cost visible while historical owner metrics are still loading', () => { + const current: OpenCostWorkloadDetailResponse = { + available: true, + namespace: 'default', + kind: 'Deployment', + name: 'checkout', + current: { + name: 'checkout', + kind: 'Deployment', + hourlyCost: 0.2, + cpuCost: 0.12, + memoryCost: 0.08, + replicas: 2, + }, + } + + expect(getWorkloadCostState(current, undefined, { trendLoading: true })).toBe('data') + }) + it('uses historical data when current metrics are absent but history exists', () => { const current: OpenCostWorkloadDetailResponse = { available: false, diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index cb0ec6719..14e5e21fc 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -40,10 +40,13 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) const [noPrometheusSince, setNoPrometheusSince] = useState(null) const currentQuery = useOpenCostWorkload(kind, namespace, name) const trendQuery = useOpenCostWorkloadTrend(kind, namespace, name, range) + const trendMatchesRange = trendQuery.data?.range === range + const trendData = trendMatchesRange ? trendQuery.data : undefined + const trendLoading = trendQuery.isLoading || (trendQuery.isFetching && Boolean(trendQuery.data) && !trendMatchesRange) - const state = getWorkloadCostState(currentQuery.data, trendQuery.data, { + const state = getWorkloadCostState(currentQuery.data, trendData, { currentLoading: currentQuery.isLoading, - trendLoading: trendQuery.isLoading, + trendLoading, currentError: currentQuery.isError, trendError: trendQuery.isError, }) @@ -83,11 +86,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) } const current = currentQuery.data?.current - const trend = trendQuery.data + const trend = trendData const points = trend?.available ? trend.dataPoints ?? [] : [] const hasTrend = points.length >= 2 && points.some((p) => p.value > 0) const hasCurrent = Boolean(current) - const trendLoading = trendQuery.isLoading && !trend const hourly = current?.hourlyCost ?? 0 const monthly = hourly * 730 const windowTotal = trend?.available ? trend.windowTotalCost ?? 0 : 0 From 87f4acdc2fd94679055721211b6aa0b79961c1e1 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Fri, 10 Jul 2026 12:36:45 +0300 Subject: [PATCH 06/12] Add application cost tab --- internal/server/opencost_application.go | 164 +++++++ internal/server/server.go | 2 + .../applications/ApplicationDetail.test.tsx | 27 ++ .../applications/ApplicationDetail.tsx | 84 +++- pkg/opencost/application_cost.go | 178 +++++++ pkg/opencost/application_cost_test.go | 58 +++ pkg/opencost/application_trend.go | 266 +++++++++++ pkg/opencost/application_trend_test.go | 112 +++++ pkg/opencost/types.go | 85 ++++ web/src/api/client.ts | 110 ++++- .../applications/ApplicationsView.tsx | 44 +- .../cost/ApplicationCostTab.test.ts | 59 +++ .../components/cost/ApplicationCostTab.tsx | 445 ++++++++++++++++++ web/src/components/cost/CostTrendChart.tsx | 2 +- web/src/components/cost/CostView.tsx | 17 +- .../components/cost/WorkloadCostTab.test.ts | 3 +- web/src/components/cost/WorkloadCostTab.tsx | 48 +- web/src/components/cost/chart.ts | 38 ++ web/src/components/cost/format.ts | 13 + web/src/components/cost/kinds.ts | 5 + web/src/components/workload/WorkloadView.tsx | 5 +- 21 files changed, 1676 insertions(+), 89 deletions(-) create mode 100644 internal/server/opencost_application.go create mode 100644 pkg/opencost/application_cost.go create mode 100644 pkg/opencost/application_cost_test.go create mode 100644 pkg/opencost/application_trend.go create mode 100644 pkg/opencost/application_trend_test.go create mode 100644 web/src/components/cost/ApplicationCostTab.test.ts create mode 100644 web/src/components/cost/ApplicationCostTab.tsx create mode 100644 web/src/components/cost/chart.ts create mode 100644 web/src/components/cost/kinds.ts diff --git a/internal/server/opencost_application.go b/internal/server/opencost_application.go new file mode 100644 index 000000000..6b9c6e4c1 --- /dev/null +++ b/internal/server/opencost_application.go @@ -0,0 +1,164 @@ +package server + +import ( + "encoding/json" + "log" + "net/http" + "sort" + "strings" + + internalopencost "github.com/skyhook-io/radar/internal/opencost" + prometheuspkg "github.com/skyhook-io/radar/internal/prometheus" + pkgopencost "github.com/skyhook-io/radar/pkg/opencost" +) + +const maxOpenCostApplicationWorkloads = 100 + +type openCostApplicationRequest struct { + Range string `json:"range,omitempty"` + Workloads []pkgopencost.ApplicationWorkloadRef `json:"workloads"` +} + +func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Request) { + if !s.requireConnected(w) { + return + } + + _, inputs, unsupported, ok := s.parseOpenCostApplicationRequest(w, r) + if !ok { + return + } + + client := prometheuspkg.GetClient() + if client == nil { + s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unsupported, pkgopencost.ReasonNoPrometheus)) + return + } + if _, _, err := client.EnsureConnected(r.Context()); err != nil { + log.Printf("[opencost] EnsureConnected failed (application cost): %v", err) + s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unsupported, pkgopencost.ReasonNoPrometheus)) + return + } + + namespaceCosts := make(map[string]*pkgopencost.WorkloadCostResponse) + for _, namespace := range applicationInputNamespaces(inputs) { + namespaceCosts[namespace] = pkgopencost.ComputeWorkloadsFromProm( + r.Context(), client.Prom(), namespace, internalopencost.BuildPodOwnerLookup(namespace)) + } + + s.writeJSON(w, pkgopencost.BuildApplicationCostResponse(inputs, unsupported, namespaceCosts)) +} + +func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.Request) { + if !s.requireConnected(w) { + return + } + + req, inputs, unsupported, ok := s.parseOpenCostApplicationRequest(w, r) + if !ok { + return + } + refs := make([]pkgopencost.ApplicationWorkloadRef, 0, len(inputs)+len(unsupported)) + for _, input := range inputs { + refs = append(refs, input.ApplicationWorkloadRef) + } + refs = append(refs, unsupported...) + + client := prometheuspkg.GetClient() + if client == nil { + s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ + Range: req.Range, + Workloads: refs, + })) + return + } + if _, _, err := client.EnsureConnected(r.Context()); err != nil { + log.Printf("[opencost] EnsureConnected failed (application trend): %v", err) + s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ + Range: req.Range, + Workloads: refs, + })) + return + } + + s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.ApplicationTrendOptions{ + Range: req.Range, + Workloads: refs, + })) +} + +func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http.Request) (openCostApplicationRequest, []pkgopencost.ApplicationWorkloadCostInput, []pkgopencost.ApplicationWorkloadRef, bool) { + var req openCostApplicationRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024)).Decode(&req); err != nil { + s.writeError(w, http.StatusBadRequest, "invalid application cost request") + return req, nil, nil, false + } + if len(req.Workloads) == 0 { + s.writeError(w, http.StatusBadRequest, "at least one workload is required") + return req, nil, nil, false + } + if len(req.Workloads) > maxOpenCostApplicationWorkloads { + s.writeError(w, http.StatusBadRequest, "too many workloads requested") + return req, nil, nil, false + } + + inputs := make([]pkgopencost.ApplicationWorkloadCostInput, 0, len(req.Workloads)) + unsupported := make([]pkgopencost.ApplicationWorkloadRef, 0) + seen := make(map[string]bool, len(req.Workloads)) + for _, ref := range req.Workloads { + ref.Kind = strings.TrimSpace(ref.Kind) + ref.Namespace = strings.TrimSpace(ref.Namespace) + ref.Name = strings.TrimSpace(ref.Name) + if ref.Kind == "" || ref.Namespace == "" || ref.Name == "" || ref.Namespace == "_" { + s.writeError(w, http.StatusBadRequest, "workload kind, namespace, and name are required") + return req, nil, nil, false + } + + kind, supported := pkgopencost.CanonicalWorkloadKind(ref.Kind) + if supported { + ref.Kind = kind + } + key := ref.Namespace + "/" + ref.Kind + "/" + ref.Name + if seen[key] { + continue + } + seen[key] = true + + if !supported { + unsupported = append(unsupported, ref) + continue + } + if status, msg, ok := s.preflightResourceGet(r, normalizeKind(ref.Kind), ref.Namespace, ref.Name, "apps"); !ok { + s.writeError(w, status, msg) + return req, nil, nil, false + } + _, desiredReplicas, ok := s.loadOpenCostWorkloadResource(w, ref.Kind, ref.Namespace, ref.Name) + if !ok { + return req, nil, nil, false + } + inputs = append(inputs, pkgopencost.ApplicationWorkloadCostInput{ + ApplicationWorkloadRef: ref, + DesiredReplicas: desiredReplicas, + }) + } + sort.Slice(inputs, func(i, j int) bool { + return inputs[i].Namespace+"/"+inputs[i].Kind+"/"+inputs[i].Name < inputs[j].Namespace+"/"+inputs[j].Kind+"/"+inputs[j].Name + }) + sort.Slice(unsupported, func(i, j int) bool { + return unsupported[i].Namespace+"/"+unsupported[i].Kind+"/"+unsupported[i].Name < unsupported[j].Namespace+"/"+unsupported[j].Kind+"/"+unsupported[j].Name + }) + return req, inputs, unsupported, true +} + +func applicationInputNamespaces(inputs []pkgopencost.ApplicationWorkloadCostInput) []string { + seen := make(map[string]bool) + for _, input := range inputs { + seen[input.Namespace] = true + } + namespaces := make([]string, 0, len(seen)) + for namespace := range seen { + namespaces = append(namespaces, namespace) + } + sort.Strings(namespaces) + return namespaces +} diff --git a/internal/server/server.go b/internal/server/server.go index 6125d51a6..936dca56c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -510,6 +510,8 @@ func (s *Server) setupRoutes() { prometheuspkg.RegisterRoutes(r) // OpenCost routes + r.Post("/opencost/application", s.handleOpenCostApplication) + r.Post("/opencost/application/trend", s.handleOpenCostApplicationTrend) r.Get("/opencost/workload/{kind}/{namespace}/{name}", s.handleOpenCostWorkload) r.Get("/opencost/workload/{kind}/{namespace}/{name}/trend", s.handleOpenCostWorkloadTrend) opencost.RegisterRoutes(r) diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.test.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.test.tsx index 17df35e8c..a11ee2918 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.test.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.test.tsx @@ -109,6 +109,33 @@ describe('ApplicationDetail shell', () => { expect(html).not.toContain('Source & provenance') }) + it('renders the optional application cost view only when the host selects it', () => { + const withCostTab = renderDetail({ + selectedView: 'overview', + onSelectView: () => {}, + selectedWorkloadKey: null, + onSelectWorkload: () => {}, + renderCostView: () =>
Application cost body
, + }) + + expect(withCostTab).toContain('Cost') + expect(withCostTab).not.toContain('Application cost body') + + const selectedCost = renderDetail({ + selectedView: 'overview', + onSelectView: () => {}, + selectedWorkloadKey: null, + onSelectWorkload: () => {}, + renderCostView: () =>
Application cost body
, + costViewSelected: true, + onSelectCostView: () => {}, + }) + + expect(selectedCost).toContain('Cost') + expect(selectedCost).toContain('Application cost body') + expect(selectedCost).not.toContain('Source & provenance') + }) + it('does not render a workload selector for a single-workload app', () => { const singleApp: AppRow = { ...app, diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index df10bb4b7..0d2430be8 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react' -import { AlertTriangle, ArrowLeft, Boxes, CheckCircle2, ChevronDown, Clock3, ExternalLink, Layers, Search } from 'lucide-react' +import { AlertTriangle, ArrowLeft, Boxes, CheckCircle2, ChevronDown, Clock3, DollarSign, ExternalLink, Layers, Search } from 'lucide-react' import { clsx } from 'clsx' import type { ResourceRef, Topology, TopologyNode } from '../../types' import { StatusDot, mapHealthToTone } from '../ui/status-tone' @@ -64,9 +64,13 @@ export interface AppIdentityInstance { * the application itself is selected; a key (see `workloadKey`) selects a * workload scope. */ type SelectionProps = - | { selectedWorkloadKey: string | null; onSelectWorkload: (key: string | null) => void } + | { selectedWorkloadKey: string | null; onSelectWorkload: (key: string | null, options?: AppWorkloadSelectionOptions) => void } | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined } +export interface AppWorkloadSelectionOptions { + tab?: string +} + type CanonicalApplicationView = 'overview' | 'topology' | 'history' export type ApplicationView = CanonicalApplicationView @@ -92,6 +96,14 @@ export type ApplicationDetailProps = { onBack: () => void /** Render the host's WorkloadView for the chosen workload. */ renderWorkload: (workload: SelectedAppWorkload) => ReactNode + /** Optional host-rendered app-scope Cost view. When absent, no Cost tab is shown. */ + renderCostView?: (props: { + app: AppRow + workloads: AppWorkload[] + onSelectWorkload: (workload: AppWorkload, options?: AppWorkloadSelectionOptions) => void + }) => ReactNode + costViewSelected?: boolean + onSelectCostView?: () => void /** Resources-view topology spanning the app's namespaces. When present, it * powers the application Topology view and workload hover focus. */ topology?: Topology @@ -160,7 +172,7 @@ function compareDefinedVersions(a: string | undefined, b: string | undefined): n return compareVersions(a, b) ?? 0 } -export function ApplicationDetail({ app, onBack, renderWorkload, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, history, historyLoading, onOpenSource, selectedWorkloadKey, onSelectWorkload, selectedView, onSelectView }: ApplicationDetailProps) { +export function ApplicationDetail({ app, onBack, renderWorkload, renderCostView, costViewSelected, onSelectCostView, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, history, historyLoading, onOpenSource, selectedWorkloadKey, onSelectWorkload, selectedView, onSelectView }: ApplicationDetailProps) { // Stable order regardless of API ordering: rail rows and the per-workload // color assignment both follow this array, so an order flap between // refetches must not reshuffle rows or reassign a workload's hue. @@ -188,20 +200,34 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol const env = app.identity?.env ?? resolvedEnv.env const inferred = app.identity ? identityEnvInferred(app.identity) : resolvedEnv.inferred const [internalView, setInternalView] = useState(DEFAULT_APPLICATION_VIEW) + const [internalCostSelected, setInternalCostSelected] = useState(false) const activeView = canonicalApplicationView(selectedView ?? internalView) + const costSelected = Boolean(renderCostView && (costViewSelected ?? internalCostSelected)) const setView = useCallback( - (view: CanonicalApplicationView) => (onSelectView ? onSelectView(view) : setInternalView(view)), + (view: CanonicalApplicationView) => { + if (onSelectView) onSelectView(view) + else { + setInternalView(view) + setInternalCostSelected(false) + } + }, [onSelectView], ) + const selectCostView = useCallback(() => { + if (!renderCostView) return + if (onSelectCostView) onSelectCostView() + else setInternalCostSelected(true) + }, [onSelectCostView, renderCostView]) useEffect(() => { if (selectedView === undefined) setInternalView(DEFAULT_APPLICATION_VIEW) + setInternalCostSelected(false) }, [app.key, selectedView]) const [internalSelected, setInternalSelected] = useState(null) const implicitSingleWorkloadKey = workloads.length === 1 ? workloadKey(workloads[0]) : null const rawSelected = selectedWorkloadKey !== undefined ? selectedWorkloadKey : (internalSelected ?? implicitSingleWorkloadKey) const setSelected = useCallback( - (key: string | null) => (onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key)), + (key: string | null, options?: AppWorkloadSelectionOptions) => (onSelectWorkload ? onSelectWorkload(key, options) : setInternalSelected(key)), [onSelectWorkload], ) const selectedWorkload = rawSelected ? workloads.find((w) => workloadKey(w) === rawSelected) : undefined @@ -375,7 +401,10 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol setSelected(workloadKey(workload))} + onSelectWorkload={(workload, options) => setSelected(workloadKey(workload), options)} onNavigateToResource={onNavigateToResource} history={history} historyLoading={historyLoading} @@ -407,7 +436,10 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol function ApplicationWorkspace({ app, activeView, + costSelected, onViewChange, + onCostViewChange, + renderCostView, workloads, namespace, namespaces, @@ -432,7 +464,10 @@ function ApplicationWorkspace({ }: { app: AppRow activeView: CanonicalApplicationView + costSelected: boolean onViewChange: (view: CanonicalApplicationView) => void + onCostViewChange: () => void + renderCostView?: ApplicationDetailProps['renderCostView'] workloads: AppWorkload[] namespace: string namespaces: string[] @@ -449,7 +484,7 @@ function ApplicationWorkspace({ onNodeClick: (node: TopologyNode) => void onNodeHover: (node: TopologyNode | null) => void onFocusWorkload: (owner: WorkloadFocus) => void - onSelectWorkload: (workload: AppWorkload) => void + onSelectWorkload: (workload: AppWorkload, options?: AppWorkloadSelectionOptions) => void onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void history?: AppHistory historyLoading?: boolean @@ -460,10 +495,14 @@ function ApplicationWorkspace({
- {activeView === 'overview' && ( + {costSelected && renderCostView && renderCostView({ app, workloads, onSelectWorkload })} + {!costSelected && activeView === 'overview' && ( )} - {activeView === 'topology' && ( + {!costSelected && activeView === 'topology' && ( )} - {activeView === 'history' && } + {!costSelected && activeView === 'history' && }
) } function ApplicationViewTabs({ activeView, + costSelected, + costAvailable, historyCount, onChange, + onCostChange, }: { activeView: CanonicalApplicationView + costSelected: boolean + costAvailable: boolean historyCount: number onChange: (view: CanonicalApplicationView) => void + onCostChange: () => void }) { return (
{APPLICATION_VIEWS.map((view) => { - const active = view.id === activeView + const active = !costSelected && view.id === activeView const badge = view.id === 'history' && historyCount > 0 ? historyCount : null return ( + )}
) diff --git a/pkg/opencost/application_cost.go b/pkg/opencost/application_cost.go new file mode 100644 index 000000000..9addd158c --- /dev/null +++ b/pkg/opencost/application_cost.go @@ -0,0 +1,178 @@ +package opencost + +import "sort" + +// BuildApplicationCostResponse folds namespace workload-cost responses into a +// single app-scoped current-cost response. +func BuildApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unsupported []ApplicationWorkloadRef, namespaceCosts map[string]*WorkloadCostResponse) *ApplicationCostResponse { + out := &ApplicationCostResponse{ + Coverage: ApplicationCostCoverage{ + Total: len(inputs) + len(unsupported), + Unsupported: append([]ApplicationWorkloadRef(nil), unsupported...), + }, + } + + for _, input := range inputs { + row := focusApplicationWorkloadCost(input, namespaceCosts[input.Namespace]) + out.Workloads = append(out.Workloads, row) + if row.Available && row.Current != nil { + out.Coverage.Included++ + addApplicationCostTotal(&out.Totals, *row.Current) + continue + } + out.Coverage.Unavailable = append(out.Coverage.Unavailable, ApplicationWorkloadStatus{ + ApplicationWorkloadRef: input.ApplicationWorkloadRef, + Reason: row.Reason, + ScaledToZero: row.ScaledToZero, + }) + } + + finalizeApplicationCostTotals(&out.Totals) + sort.Slice(out.Workloads, func(i, j int) bool { + left, right := out.Workloads[i], out.Workloads[j] + leftCost, rightCost := 0.0, 0.0 + if left.Current != nil { + leftCost = left.Current.HourlyCost + } + if right.Current != nil { + rightCost = right.Current.HourlyCost + } + if leftCost != rightCost { + return leftCost > rightCost + } + return applicationRefSortKey(left.ApplicationWorkloadRef) < applicationRefSortKey(right.ApplicationWorkloadRef) + }) + sortApplicationRefs(out.Coverage.Unsupported) + sortApplicationStatuses(out.Coverage.Unavailable) + + out.Available = out.Coverage.Included > 0 + out.Partial = len(out.Coverage.Unsupported) > 0 || len(out.Coverage.Unavailable) > 0 + if !out.Available { + out.Reason = applicationUnavailableReason(out.Coverage.Unavailable) + } + return out +} + +// UnavailableApplicationCostResponse returns a shaped app response for +// cluster-wide failures that happen before per-namespace cost can be queried. +func UnavailableApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unsupported []ApplicationWorkloadRef, reason string) *ApplicationCostResponse { + statuses := make([]ApplicationWorkloadStatus, 0, len(inputs)) + for _, input := range inputs { + statuses = append(statuses, ApplicationWorkloadStatus{ + ApplicationWorkloadRef: input.ApplicationWorkloadRef, + Reason: reason, + }) + } + sortApplicationStatuses(statuses) + unsupportedCopy := append([]ApplicationWorkloadRef(nil), unsupported...) + sortApplicationRefs(unsupportedCopy) + return &ApplicationCostResponse{ + Available: false, + Reason: reason, + Partial: len(unsupportedCopy) > 0, + Coverage: ApplicationCostCoverage{ + Total: len(inputs) + len(unsupportedCopy), + Unavailable: statuses, + Unsupported: unsupportedCopy, + }, + } +} + +func focusApplicationWorkloadCost(input ApplicationWorkloadCostInput, resp *WorkloadCostResponse) ApplicationWorkloadCost { + row := ApplicationWorkloadCost{ApplicationWorkloadRef: input.ApplicationWorkloadRef} + if resp == nil { + row.Reason = ReasonQueryError + return row + } + if resp.Available { + for i := range resp.Workloads { + wl := resp.Workloads[i] + if wl.Kind == input.Kind && wl.Name == input.Name { + row.Available = true + row.Current = &wl + return row + } + } + if input.DesiredReplicas == 0 { + row.Available = true + row.ScaledToZero = true + row.Current = zeroApplicationWorkloadCost(input.Kind, input.Name) + return row + } + row.Reason = ReasonNoMetrics + return row + } + if resp.Reason == ReasonNoMetrics && input.DesiredReplicas == 0 { + row.Available = true + row.ScaledToZero = true + row.Current = zeroApplicationWorkloadCost(input.Kind, input.Name) + return row + } + row.Reason = resp.Reason + if row.Reason == "" { + row.Reason = ReasonQueryError + } + return row +} + +func zeroApplicationWorkloadCost(kind, name string) *WorkloadCost { + return &WorkloadCost{ + Name: name, + Kind: kind, + HourlyCost: 0, + CPUCost: 0, + MemoryCost: 0, + Replicas: 0, + Efficiency: 0, + IdleCost: 0, + } +} + +func addApplicationCostTotal(total *ApplicationCostTotals, wl WorkloadCost) { + total.HourlyCost += wl.HourlyCost + total.CPUCost += wl.CPUCost + total.MemoryCost += wl.MemoryCost + total.Replicas += wl.Replicas + total.CPUUsageCost += wl.CPUUsageCost + total.MemoryUsageCost += wl.MemoryUsageCost +} + +func finalizeApplicationCostTotals(total *ApplicationCostTotals) { + allocCost := total.CPUCost + total.MemoryCost + usageCost := total.CPUUsageCost + total.MemoryUsageCost + total.HourlyCost = roundTo(total.HourlyCost, 4) + total.CPUCost = roundTo(total.CPUCost, 4) + total.MemoryCost = roundTo(total.MemoryCost, 4) + total.CPUUsageCost = roundTo(total.CPUUsageCost, 4) + total.MemoryUsageCost = roundTo(total.MemoryUsageCost, 4) + total.Efficiency = efficiencyPct(usageCost, allocCost) + total.IdleCost = roundTo(idleFromUsage(usageCost, allocCost), 4) +} + +func applicationUnavailableReason(statuses []ApplicationWorkloadStatus) string { + for _, status := range statuses { + if status.Reason == ReasonQueryError || status.Reason == ReasonNoPrometheus { + return status.Reason + } + } + if len(statuses) > 0 { + return statuses[0].Reason + } + return ReasonNoMetrics +} + +func sortApplicationRefs(refs []ApplicationWorkloadRef) { + sort.Slice(refs, func(i, j int) bool { + return applicationRefSortKey(refs[i]) < applicationRefSortKey(refs[j]) + }) +} + +func sortApplicationStatuses(statuses []ApplicationWorkloadStatus) { + sort.Slice(statuses, func(i, j int) bool { + return applicationRefSortKey(statuses[i].ApplicationWorkloadRef) < applicationRefSortKey(statuses[j].ApplicationWorkloadRef) + }) +} + +func applicationRefSortKey(ref ApplicationWorkloadRef) string { + return ref.Namespace + "/" + ref.Kind + "/" + ref.Name +} diff --git a/pkg/opencost/application_cost_test.go b/pkg/opencost/application_cost_test.go new file mode 100644 index 000000000..e5e77afb9 --- /dev/null +++ b/pkg/opencost/application_cost_test.go @@ -0,0 +1,58 @@ +package opencost + +import "testing" + +func TestBuildApplicationCostResponse_PartialAndScaledToZero(t *testing.T) { + inputs := []ApplicationWorkloadCostInput{ + {ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "Deployment", Name: "api"}, DesiredReplicas: 2}, + {ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "Deployment", Name: "scaled"}, DesiredReplicas: 0}, + {ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "StatefulSet", Name: "missing"}, DesiredReplicas: 1}, + } + unsupported := []ApplicationWorkloadRef{{Namespace: "default", Kind: "Job", Name: "import"}} + got := BuildApplicationCostResponse(inputs, unsupported, map[string]*WorkloadCostResponse{ + "default": { + Available: true, + Namespace: "default", + Workloads: []WorkloadCost{{ + Name: "api", + Kind: "Deployment", + HourlyCost: 0.2, + CPUCost: 0.12, + MemoryCost: 0.08, + Replicas: 2, + CPUUsageCost: 0.03, + MemoryUsageCost: 0.02, + }}, + }, + }) + + if !got.Available { + t.Fatalf("expected available partial response, got %+v", got) + } + if !got.Partial { + t.Fatalf("expected Partial=true, got %+v", got) + } + if got.Coverage.Total != 4 || got.Coverage.Included != 2 { + t.Fatalf("coverage = %+v, want total=4 included=2", got.Coverage) + } + if len(got.Coverage.Unavailable) != 1 || got.Coverage.Unavailable[0].Name != "missing" || got.Coverage.Unavailable[0].Reason != ReasonNoMetrics { + t.Fatalf("unexpected unavailable coverage: %+v", got.Coverage.Unavailable) + } + if len(got.Coverage.Unsupported) != 1 || got.Coverage.Unsupported[0].Kind != "Job" { + t.Fatalf("unexpected unsupported coverage: %+v", got.Coverage.Unsupported) + } + if got.Totals.HourlyCost != 0.2 || got.Totals.CPUCost != 0.12 || got.Totals.MemoryCost != 0.08 || got.Totals.Replicas != 2 { + t.Fatalf("totals = %+v", got.Totals) + } + + var scaled *ApplicationWorkloadCost + for i := range got.Workloads { + if got.Workloads[i].Name == "scaled" { + scaled = &got.Workloads[i] + break + } + } + if scaled == nil || !scaled.Available || !scaled.ScaledToZero || scaled.Current == nil || scaled.Current.HourlyCost != 0 { + t.Fatalf("scaled-to-zero row not preserved as valid zero: %+v", scaled) + } +} diff --git a/pkg/opencost/application_trend.go b/pkg/opencost/application_trend.go new file mode 100644 index 000000000..9a92158f0 --- /dev/null +++ b/pkg/opencost/application_trend.go @@ -0,0 +1,266 @@ +package opencost + +import ( + "context" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/skyhook-io/radar/pkg/prom" +) + +type ApplicationTrendOptions struct { + Range string + Workloads []ApplicationWorkloadRef +} + +func ComputeApplicationCostTrendFromProm(ctx context.Context, client *prom.Client, opts ApplicationTrendOptions) *ApplicationCostTrendResponse { + start, end, step, label := resolveTrendRange(opts.Range) + supported, unsupported := splitApplicationTrendRefs(opts.Workloads) + resp := &ApplicationCostTrendResponse{ + Range: label, + Coverage: ApplicationCostCoverage{ + Total: len(supported) + len(unsupported), + Unsupported: unsupported, + }, + } + if client == nil { + resp.Available = false + resp.Reason = ReasonNoPrometheus + resp.Coverage.Unavailable = applicationStatusesForRefs(supported, ReasonNoPrometheus) + resp.Partial = len(resp.Coverage.Unsupported) > 0 + return resp + } + if len(supported) == 0 { + resp.Available = false + resp.Reason = ReasonNoMetrics + resp.Partial = len(resp.Coverage.Unsupported) > 0 + return resp + } + + byNamespace := groupApplicationRefsByNamespace(supported) + for _, namespace := range sortedNamespaceKeys(byNamespace) { + refs := byNamespace[namespace] + result, reason := queryApplicationTrendNamespace(ctx, client, namespace, refs, start, end, step, label) + if reason != "" { + resp.Coverage.Unavailable = append(resp.Coverage.Unavailable, applicationStatusesForRefs(refs, reason)...) + continue + } + + seen := make(map[string]bool, len(refs)) + requested := make(map[string]ApplicationWorkloadRef, len(refs)) + for _, ref := range refs { + requested[applicationRefSortKey(ref)] = ref + } + for _, series := range result.Series { + kind := series.Labels["owner_kind"] + name := series.Labels["owner_name"] + ref := ApplicationWorkloadRef{Namespace: namespace, Kind: kind, Name: name} + key := applicationRefSortKey(ref) + canonicalRef, ok := requested[key] + if !ok || len(series.DataPoints) == 0 { + continue + } + points := roundedCostDataPoints(series.DataPoints) + resp.Series = append(resp.Series, ApplicationCostTrendSeries{ + ApplicationWorkloadRef: canonicalRef, + WindowTotalCost: roundTo(integrateHourlyCost(points), 4), + DataPoints: points, + }) + seen[key] = true + } + for _, ref := range refs { + if !seen[applicationRefSortKey(ref)] { + resp.Coverage.Unavailable = append(resp.Coverage.Unavailable, ApplicationWorkloadStatus{ + ApplicationWorkloadRef: ref, + Reason: ReasonNoMetrics, + }) + } + } + } + + sort.Slice(resp.Series, func(i, j int) bool { + if resp.Series[i].WindowTotalCost != resp.Series[j].WindowTotalCost { + return resp.Series[i].WindowTotalCost > resp.Series[j].WindowTotalCost + } + return applicationRefSortKey(resp.Series[i].ApplicationWorkloadRef) < applicationRefSortKey(resp.Series[j].ApplicationWorkloadRef) + }) + sortApplicationRefs(resp.Coverage.Unsupported) + sortApplicationStatuses(resp.Coverage.Unavailable) + resp.Coverage.Included = len(resp.Series) + resp.Partial = len(resp.Coverage.Unsupported) > 0 || len(resp.Coverage.Unavailable) > 0 + resp.Available = resp.Coverage.Included > 0 + if resp.Available { + resp.DataPoints = mergeApplicationCostDataPoints(resp.Series) + resp.WindowTotalCost = roundTo(integrateHourlyCost(resp.DataPoints), 4) + return resp + } + resp.Reason = applicationUnavailableReason(resp.Coverage.Unavailable) + return resp +} + +func queryApplicationTrendNamespace(ctx context.Context, client *prom.Client, namespace string, refs []ApplicationWorkloadRef, start, end time.Time, step time.Duration, label string) (*prom.QueryResult, string) { + query := buildApplicationTrendQuery(namespace, refs, false) + if query == "" { + return nil, ReasonNoMetrics + } + result, err := client.QueryRange(ctx, query, start, end, step) + if err != nil { + log.Printf("[opencost] app workload trend range query failed for ns=%s (range=%s), trying opencost_container_* fallback: %v", namespace, label, err) + result, err = client.QueryRange(ctx, buildApplicationTrendQuery(namespace, refs, true), start, end, step) + if err != nil { + log.Printf("[opencost] app workload trend fallback query failed for ns=%s (range=%s): %v", namespace, label, err) + return nil, ReasonQueryError + } + } + if result == nil || len(result.Series) == 0 { + return nil, ReasonNoMetrics + } + return result, "" +} + +func buildApplicationTrendQuery(namespace string, refs []ApplicationWorkloadRef, fallback bool) string { + safeNS := prom.SanitizeLabelValue(namespace) + podCost := workloadPodCostExpr(safeNS, fallback) + namesByKind := make(map[string][]string) + for _, ref := range refs { + namesByKind[ref.Kind] = append(namesByKind[ref.Kind], ref.Name) + } + + exprs := make([]string, 0, 3) + if names := namesByKind["Deployment"]; len(names) > 0 { + exprs = append(exprs, fmt.Sprintf(`( + (%s) + * on(namespace, pod) group_left(replicaset) + max by (namespace, pod, replicaset) ( + label_replace(kube_pod_owner{namespace="%s", owner_kind="ReplicaSet"}, "replicaset", "$1", "owner_name", "(.+)") + ) + * on(namespace, replicaset) group_left(owner_kind, owner_name) + max by (namespace, replicaset, owner_kind, owner_name) ( + kube_replicaset_owner{namespace="%s", owner_kind="Deployment", owner_name=~"%s"} + ) +)`, podCost, safeNS, safeNS, promRegexAlternation(names))) + } + for _, kind := range []string{"StatefulSet", "DaemonSet"} { + if names := namesByKind[kind]; len(names) > 0 { + exprs = append(exprs, fmt.Sprintf(`( + (%s) + * on(namespace, pod) group_left(owner_kind, owner_name) + max by (namespace, pod, owner_kind, owner_name) ( + kube_pod_owner{namespace="%s", owner_kind="%s", owner_name=~"%s"} + ) +)`, podCost, safeNS, kind, promRegexAlternation(names))) + } + } + if len(exprs) == 0 { + return "" + } + return "sum by (owner_kind, owner_name) (\n" + strings.Join(exprs, "\n or \n") + "\n)" +} + +func splitApplicationTrendRefs(refs []ApplicationWorkloadRef) ([]ApplicationWorkloadRef, []ApplicationWorkloadRef) { + supported := make([]ApplicationWorkloadRef, 0, len(refs)) + unsupported := make([]ApplicationWorkloadRef, 0) + seen := make(map[string]bool, len(refs)) + for _, ref := range refs { + if ref.Namespace == "" || ref.Name == "" { + continue + } + kind, ok := CanonicalWorkloadKind(ref.Kind) + normalized := ApplicationWorkloadRef{Kind: kind, Namespace: ref.Namespace, Name: ref.Name} + if !ok { + normalized.Kind = ref.Kind + key := applicationRefSortKey(normalized) + if !seen[key] { + unsupported = append(unsupported, normalized) + seen[key] = true + } + continue + } + key := applicationRefSortKey(normalized) + if seen[key] { + continue + } + supported = append(supported, normalized) + seen[key] = true + } + sortApplicationRefs(supported) + sortApplicationRefs(unsupported) + return supported, unsupported +} + +func groupApplicationRefsByNamespace(refs []ApplicationWorkloadRef) map[string][]ApplicationWorkloadRef { + out := make(map[string][]ApplicationWorkloadRef) + for _, ref := range refs { + out[ref.Namespace] = append(out[ref.Namespace], ref) + } + for ns := range out { + sortApplicationRefs(out[ns]) + } + return out +} + +func sortedNamespaceKeys(refs map[string][]ApplicationWorkloadRef) []string { + namespaces := make([]string, 0, len(refs)) + for namespace := range refs { + namespaces = append(namespaces, namespace) + } + sort.Strings(namespaces) + return namespaces +} + +func applicationStatusesForRefs(refs []ApplicationWorkloadRef, reason string) []ApplicationWorkloadStatus { + statuses := make([]ApplicationWorkloadStatus, 0, len(refs)) + for _, ref := range refs { + statuses = append(statuses, ApplicationWorkloadStatus{ + ApplicationWorkloadRef: ref, + Reason: reason, + }) + } + sortApplicationStatuses(statuses) + return statuses +} + +func roundedCostDataPoints(points []prom.DataPoint) []CostDataPoint { + out := make([]CostDataPoint, 0, len(points)) + for _, point := range points { + out = append(out, CostDataPoint{ + Timestamp: point.Timestamp, + Value: roundTo(point.Value, 4), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Timestamp < out[j].Timestamp }) + return out +} + +func mergeApplicationCostDataPoints(series []ApplicationCostTrendSeries) []CostDataPoint { + byTS := make(map[int64]float64) + for _, s := range series { + for _, dp := range s.DataPoints { + byTS[dp.Timestamp] += dp.Value + } + } + points := make([]CostDataPoint, 0, len(byTS)) + for ts, value := range byTS { + points = append(points, CostDataPoint{Timestamp: ts, Value: roundTo(value, 4)}) + } + sort.Slice(points, func(i, j int) bool { return points[i].Timestamp < points[j].Timestamp }) + return points +} + +func promRegexAlternation(values []string) string { + copied := append([]string(nil), values...) + sort.Strings(copied) + unique := copied[:0] + var prev string + for i, value := range copied { + if i > 0 && value == prev { + continue + } + unique = append(unique, prom.EscapeRegexMeta(value)) + prev = value + } + return strings.Join(unique, "|") +} diff --git a/pkg/opencost/application_trend_test.go b/pkg/opencost/application_trend_test.go new file mode 100644 index 000000000..a8fa646b7 --- /dev/null +++ b/pkg/opencost/application_trend_test.go @@ -0,0 +1,112 @@ +package opencost + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyhook-io/radar/pkg/prom" +) + +func TestComputeApplicationCostTrendFromProm_BatchesByNamespaceAndReportsPartialCoverage(t *testing.T) { + var queries []string + client := applicationTrendProm(t, func(q string) string { + queries = append(queries, q) + return appMatrixBody([]appTrendSeries{ + {"Deployment", "api", []dpoint{{1700000000, 1}, {1700003600, 2}}}, + {"StatefulSet", "db", []dpoint{{1700000000, 2}, {1700003600, 3}}}, + }) + }) + + got := ComputeApplicationCostTrendFromProm(context.Background(), client, ApplicationTrendOptions{ + Range: "24h", + Workloads: []ApplicationWorkloadRef{ + {Namespace: "default", Kind: "Deployment", Name: "api"}, + {Namespace: "default", Kind: "StatefulSet", Name: "db"}, + {Namespace: "default", Kind: "DaemonSet", Name: "agent"}, + {Namespace: "default", Kind: "Job", Name: "import"}, + }, + }) + + if len(queries) != 1 { + t.Fatalf("expected one range query for one namespace, got %d", len(queries)) + } + query := queries[0] + if !strings.Contains(query, `kube_replicaset_owner{namespace="default", owner_kind="Deployment", owner_name=~"api"}`) { + t.Fatalf("deployment selector missing from app trend query:\n%s", query) + } + if !strings.Contains(query, `kube_pod_owner{namespace="default", owner_kind="StatefulSet", owner_name=~"db"}`) { + t.Fatalf("statefulset selector missing from app trend query:\n%s", query) + } + if !strings.Contains(query, `kube_pod_owner{namespace="default", owner_kind="DaemonSet", owner_name=~"agent"}`) { + t.Fatalf("daemonset selector missing from app trend query:\n%s", query) + } + if !got.Available || !got.Partial { + t.Fatalf("expected available partial trend response, got %+v", got) + } + if got.Coverage.Total != 4 || got.Coverage.Included != 2 { + t.Fatalf("coverage = %+v, want total=4 included=2", got.Coverage) + } + if len(got.Coverage.Unavailable) != 1 || got.Coverage.Unavailable[0].Name != "agent" || got.Coverage.Unavailable[0].Reason != ReasonNoMetrics { + t.Fatalf("unexpected unavailable coverage: %+v", got.Coverage.Unavailable) + } + if len(got.Coverage.Unsupported) != 1 || got.Coverage.Unsupported[0].Kind != "Job" { + t.Fatalf("unexpected unsupported coverage: %+v", got.Coverage.Unsupported) + } + if got.WindowTotalCost != 5 { + t.Fatalf("WindowTotalCost = %v, want 5", got.WindowTotalCost) + } + if len(got.DataPoints) != 2 || got.DataPoints[0].Value != 3 || got.DataPoints[1].Value != 5 { + t.Fatalf("merged datapoints = %+v, want values 3 and 5", got.DataPoints) + } + if len(got.Series) != 2 || got.Series[0].Name != "db" || got.Series[1].Name != "api" { + t.Fatalf("series should be sorted by window total desc, got %+v", got.Series) + } +} + +func applicationTrendProm(t *testing.T, bodyForQuery func(string) string) *prom.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(bodyForQuery(r.URL.Query().Get("query")))) + })) + t.Cleanup(srv.Close) + return prom.NewClient(prom.NewHTTPTransport(srv.URL, "", nil)) +} + +type appTrendSeries struct { + kind string + name string + points []dpoint +} + +func appMatrixBody(series []appTrendSeries) string { + type point = []interface{} + type entry struct { + Metric map[string]string `json:"metric"` + Values []point `json:"values"` + } + body := struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []entry `json:"result"` + } `json:"data"` + }{Status: "success"} + body.Data.ResultType = "matrix" + for _, s := range series { + values := make([]point, 0, len(s.points)) + for _, p := range s.points { + values = append(values, point{float64(p.ts), formatFloat(p.v)}) + } + body.Data.Result = append(body.Data.Result, entry{ + Metric: map[string]string{"owner_kind": s.kind, "owner_name": s.name}, + Values: values, + }) + } + b, _ := json.Marshal(body) + return string(b) +} diff --git a/pkg/opencost/types.go b/pkg/opencost/types.go index 81f24b75a..27903bd33 100644 --- a/pkg/opencost/types.go +++ b/pkg/opencost/types.go @@ -92,6 +92,91 @@ type WorkloadCostTrendResponse struct { DataPoints []CostDataPoint `json:"dataPoints,omitempty"` } +// ApplicationWorkloadRef identifies one workload included in an application +// cost request. +type ApplicationWorkloadRef struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` +} + +// ApplicationWorkloadCostInput carries server-validated metadata used to +// aggregate current app cost. +type ApplicationWorkloadCostInput struct { + ApplicationWorkloadRef + DesiredReplicas int `json:"desiredReplicas,omitempty"` +} + +// ApplicationCostCoverage describes how much of an app's workload set was +// included in a cost response. +type ApplicationCostCoverage struct { + Total int `json:"total"` + Included int `json:"included"` + Unavailable []ApplicationWorkloadStatus `json:"unavailable,omitempty"` + Unsupported []ApplicationWorkloadRef `json:"unsupported,omitempty"` +} + +// ApplicationWorkloadStatus describes a workload that could not be included in +// the app cost total. +type ApplicationWorkloadStatus struct { + ApplicationWorkloadRef + Reason string `json:"reason"` + ScaledToZero bool `json:"scaledToZero,omitempty"` +} + +// ApplicationCostTotals holds summed current cost values for included app +// workloads. +type ApplicationCostTotals struct { + HourlyCost float64 `json:"hourlyCost"` + CPUCost float64 `json:"cpuCost"` + MemoryCost float64 `json:"memoryCost"` + Replicas int `json:"replicas"` + CPUUsageCost float64 `json:"cpuUsageCost,omitempty"` + MemoryUsageCost float64 `json:"memoryUsageCost,omitempty"` + Efficiency float64 `json:"efficiency,omitempty"` + IdleCost float64 `json:"idleCost,omitempty"` +} + +// ApplicationWorkloadCost is a current-cost row for one app workload. +type ApplicationWorkloadCost struct { + ApplicationWorkloadRef + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + ScaledToZero bool `json:"scaledToZero,omitempty"` + Current *WorkloadCost `json:"current,omitempty"` +} + +// ApplicationCostResponse is the focused current-cost response for an app's +// steady-state workloads. +type ApplicationCostResponse struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + Partial bool `json:"partial,omitempty"` + Totals ApplicationCostTotals `json:"totals"` + Coverage ApplicationCostCoverage `json:"coverage"` + Workloads []ApplicationWorkloadCost `json:"workloads,omitempty"` +} + +// ApplicationCostTrendSeries is one workload's historical cost series. +type ApplicationCostTrendSeries struct { + ApplicationWorkloadRef + WindowTotalCost float64 `json:"windowTotalCost,omitempty"` + DataPoints []CostDataPoint `json:"dataPoints,omitempty"` +} + +// ApplicationCostTrendResponse is the historical cost response for an app's +// steady-state workloads. +type ApplicationCostTrendResponse struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + Range string `json:"range"` + Partial bool `json:"partial,omitempty"` + WindowTotalCost float64 `json:"windowTotalCost,omitempty"` + DataPoints []CostDataPoint `json:"dataPoints,omitempty"` + Series []ApplicationCostTrendSeries `json:"series,omitempty"` + Coverage ApplicationCostCoverage `json:"coverage"` +} + // CostTrendSeries holds cost data points for a single namespace. type CostTrendSeries struct { Namespace string `json:"namespace"` diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 66735e680..03436beef 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -147,8 +147,9 @@ export function isMetricsUnavailableError(error: unknown): boolean { }) } -export async function fetchJSON(path: string, signal?: AbortSignal): Promise { - const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined) +export async function fetchJSON(path: string, init?: RequestInit | AbortSignal): Promise { + const requestInit = init instanceof AbortSignal ? { signal: init } : init + const response = await apiFetch(`${getApiBase()}${path}`, requestInit) if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Unknown error' })) throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData) @@ -704,6 +705,111 @@ export function useOpenCostWorkloadTrend(kind: string, namespace: string, name: }) } +export interface OpenCostApplicationWorkloadRef { + kind: string + namespace: string + name: string +} + +export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef { + reason: CostUnavailableReason + scaledToZero?: boolean +} + +export interface OpenCostApplicationCostCoverage { + total: number + included: number + unavailable?: OpenCostApplicationWorkloadStatus[] + unsupported?: OpenCostApplicationWorkloadRef[] +} + +export interface OpenCostApplicationCostTotals { + hourlyCost: number + cpuCost: number + memoryCost: number + replicas: number + cpuUsageCost?: number + memoryUsageCost?: number + efficiency?: number + idleCost?: number +} + +export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef { + available: boolean + reason?: CostUnavailableReason + scaledToZero?: boolean + current?: OpenCostWorkloadCost +} + +export interface OpenCostApplicationCostResponse { + available: boolean + reason?: CostUnavailableReason + partial?: boolean + totals: OpenCostApplicationCostTotals + coverage: OpenCostApplicationCostCoverage + workloads?: OpenCostApplicationWorkloadCost[] +} + +export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef { + windowTotalCost?: number + dataPoints?: OpenCostTrendDataPoint[] +} + +export interface OpenCostApplicationCostTrendResponse { + available: boolean + reason?: CostUnavailableReason + range: string + partial?: boolean + windowTotalCost?: number + dataPoints?: OpenCostTrendDataPoint[] + series?: OpenCostApplicationCostTrendSeries[] + coverage: OpenCostApplicationCostCoverage +} + +function stableOpenCostWorkloadRefs(workloads: OpenCostApplicationWorkloadRef[]): OpenCostApplicationWorkloadRef[] { + const byKey = new Map() + for (const workload of workloads) { + if (!workload.kind || !workload.namespace || !workload.name) continue + const ref = { kind: workload.kind, namespace: workload.namespace, name: workload.name } + byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref) + } + return [...byKey.values()].sort((a, b) => `${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`)) +} + +export function useOpenCostApplicationCost(workloads: OpenCostApplicationWorkloadRef[], options?: { enabled?: boolean }) { + const refs = stableOpenCostWorkloadRefs(workloads) + return useQuery({ + queryKey: ['opencost-application', refs], + queryFn: ({ signal }) => fetchJSON('/opencost/application', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workloads: refs }), + signal, + }), + enabled: (options?.enabled ?? true) && refs.length > 0, + staleTime: 30000, + refetchInterval: costRefetchInterval(), + placeholderData: (prev) => prev, + }) +} + +export function useOpenCostApplicationCostTrend(workloads: OpenCostApplicationWorkloadRef[], range_: CostTimeRange = '24h', options?: { enabled?: boolean }) { + const refs = stableOpenCostWorkloadRefs(workloads) + return useQuery({ + queryKey: ['opencost-application-trend', refs, range_], + queryFn: ({ signal }) => fetchJSON('/opencost/application/trend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workloads: refs, range: range_ }), + signal, + }), + enabled: (options?.enabled ?? true) && refs.length > 0, + staleTime: 60000, + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), + placeholderData: (prev) => prev, + }) +} + // Node cost breakdown export interface OpenCostNodeCost { name: string diff --git a/web/src/components/applications/ApplicationsView.tsx b/web/src/components/applications/ApplicationsView.tsx index cd2593ee4..9771ad070 100644 --- a/web/src/components/applications/ApplicationsView.tsx +++ b/web/src/components/applications/ApplicationsView.tsx @@ -25,12 +25,16 @@ import { useApplicationHistory, useApplications, useTopology } from '../../api/c import { useConnection } from '../../context/ConnectionContext' import { buildWorkloadPath, kindToPlural } from '../../utils/navigation' import { WorkloadView } from '../workload/WorkloadView' +import { ApplicationCostTab } from '../cost/ApplicationCostTab' +import { isOpenCostWorkloadKind } from '../cost/kinds' -const APPLICATION_VIEWS: ReadonlySet = new Set(['overview', 'topology', 'history']) +type ApplicationRouteView = ApplicationView | 'cost' -function parseApplicationView(value: string | null): ApplicationView { - if (!value || !APPLICATION_VIEWS.has(value as ApplicationView)) return 'overview' - return value as ApplicationView +const APPLICATION_VIEWS: ReadonlySet = new Set(['overview', 'topology', 'history', 'cost']) + +function parseApplicationView(value: string | null): ApplicationRouteView { + if (!value || !APPLICATION_VIEWS.has(value as ApplicationRouteView)) return 'overview' + return value as ApplicationRouteView } interface ApplicationsViewProps { @@ -146,11 +150,14 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap // workload. A single-workload app does not expose app scope. const [searchParams, setSearchParams] = useSearchParams() const viewParam = searchParams.get('view') - const selectedView = parseApplicationView(viewParam) + const selectedRouteView = parseApplicationView(viewParam) + const selectedView: ApplicationView = selectedRouteView === 'cost' ? 'overview' : selectedRouteView const selectedWorkloadParam = searchParams.get('workload') const appWorkloads = app.workloads ?? [] const singleWorkloadKey = appWorkloads.length === 1 ? workloadKey(appWorkloads[0]) : null const selectedWorkloadKey = singleWorkloadKey ?? selectedWorkloadParam + const applicationCostAvailable = !singleWorkloadKey && appWorkloads.some((workload) => isOpenCostWorkloadKind(workload.kind)) + const applicationCostSelected = selectedRouteView === 'cost' && applicationCostAvailable const historyQuery = useApplicationHistory(app.key, appHistoryNamespaces, { enabled: !selectedWorkloadKey }) useEffect(() => { if (!singleWorkloadKey) return @@ -160,13 +167,22 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap params.set('workload', singleWorkloadKey) setSearchParams(params, { replace: true }) }, [searchParams, selectedWorkloadParam, setSearchParams, singleWorkloadKey, viewParam]) + useEffect(() => { + if (selectedRouteView !== 'cost' || applicationCostAvailable) return + const params = new URLSearchParams(searchParams) + params.delete('view') + setSearchParams(params, { replace: true }) + }, [applicationCostAvailable, searchParams, selectedRouteView, setSearchParams]) const selectView = useCallback( - (view: ApplicationView) => { + (view: ApplicationRouteView) => { const params = new URLSearchParams(searchParams) params.delete('tab') if (singleWorkloadKey) { params.delete('view') params.set('workload', singleWorkloadKey) + } else if (view === 'cost') { + params.set('view', 'cost') + params.delete('workload') } else if (view === 'overview') { params.delete('view') params.delete('workload') @@ -179,13 +195,14 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap [searchParams, setSearchParams, singleWorkloadKey], ) const selectWorkload = useCallback( - (key: string | null) => { + (key: string | null, options?: { tab?: string }) => { const params = new URLSearchParams(searchParams) if (key) { const wasInWorkloadScope = !!selectedWorkloadKey params.delete('view') params.set('workload', key) - if (!wasInWorkloadScope) params.delete('tab') + if (options?.tab) params.set('tab', options.tab) + else if (!wasInWorkloadScope) params.delete('tab') } else if (singleWorkloadKey) { params.delete('view') params.set('workload', singleWorkloadKey) @@ -322,6 +339,17 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap onSelectWorkload={selectWorkload} selectedView={selectedView} onSelectView={selectView} + costViewSelected={applicationCostSelected} + onSelectCostView={() => selectView('cost')} + renderCostView={applicationCostAvailable + ? ({ app, workloads, onSelectWorkload }) => ( + onSelectWorkload(workload, { tab: 'cost' })} + /> + ) + : undefined} renderWorkload={(workload: SelectedAppWorkload) => (
{ + it('keeps current app cost visible when historical owner metrics are missing', () => { + const current: OpenCostApplicationCostResponse = { + available: true, + partial: true, + totals: { hourlyCost: 0.4, cpuCost: 0.25, memoryCost: 0.15, replicas: 3 }, + coverage: { total: 3, included: 2, unavailable: [{ kind: 'Deployment', namespace: 'prod', name: 'worker', reason: 'no_metrics' }] }, + workloads: [], + } + const trend: OpenCostApplicationCostTrendResponse = { + available: false, + reason: 'no_metrics', + range: '24h', + coverage: { total: 3, included: 0 }, + } + + expect(getApplicationCostState(current, trend, { currentLoading: false, trendLoading: false })).toBe('partial_missing_history') + }) + + it('uses historical data when current app metrics are absent but history exists', () => { + const current: OpenCostApplicationCostResponse = { + available: false, + reason: 'no_metrics', + totals: { hourlyCost: 0, cpuCost: 0, memoryCost: 0, replicas: 0 }, + coverage: { total: 2, included: 0 }, + } + const trend: OpenCostApplicationCostTrendResponse = { + available: true, + range: '7d', + windowTotalCost: 2, + dataPoints: [ + { timestamp: 1700000000, value: 0.2 }, + { timestamp: 1700003600, value: 0.3 }, + ], + coverage: { total: 2, included: 2 }, + } + + expect(getApplicationCostState(current, trend, { currentLoading: false, trendLoading: false })).toBe('partial_missing_current') + }) + + it('treats all tracked workloads scaled to zero as valid zero state', () => { + const current: OpenCostApplicationCostResponse = { + available: true, + totals: { hourlyCost: 0, cpuCost: 0, memoryCost: 0, replicas: 0 }, + coverage: { total: 1, included: 1 }, + workloads: [{ kind: 'Deployment', namespace: 'prod', name: 'api', available: true, scaledToZero: true, current: { name: 'api', kind: 'Deployment', hourlyCost: 0, cpuCost: 0, memoryCost: 0, replicas: 0 } }], + } + + expect(getApplicationCostState(current, undefined, { currentLoading: false, trendLoading: false })).toBe('zero') + }) + + it('separates query load failures from absent app metrics', () => { + expect(getApplicationCostState(undefined, undefined, { currentError: true })).toBe('load_error') + }) +}) diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx new file mode 100644 index 000000000..ab18e6fd2 --- /dev/null +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -0,0 +1,445 @@ +import { useEffect, useMemo, useState } from 'react' +import { clsx } from 'clsx' +import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react' +import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui' +import { + COST_DISCOVERY_GRACE_MS, + useOpenCostApplicationCost, + useOpenCostApplicationCostTrend, + type CostTimeRange, + type CostUnavailableReason, + type OpenCostApplicationCostResponse, + type OpenCostApplicationCostTrendResponse, + type OpenCostApplicationWorkloadCost, + type OpenCostTrendSeries, +} from '../../api/client' +import { Tooltip } from '../ui/Tooltip' +import { StackedAreaChart } from './CostTrendChart' +import { formatCost, formatCostPerHour } from './format' +import { isOpenCostWorkloadKind } from './kinds' + +const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ + { value: '6h', label: '6h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, +] + +type ApplicationCostState = 'loading' | 'data' | 'partial_missing_history' | 'partial_missing_current' | 'zero' | 'load_error' | CostUnavailableReason + +interface ApplicationCostQueryStatus { + currentLoading?: boolean + trendLoading?: boolean + currentError?: boolean + trendError?: boolean +} + +interface ApplicationCostTabProps { + app: AppRow + workloads: AppWorkload[] + onSelectWorkloadCost?: (workload: AppWorkload) => void +} + +export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: ApplicationCostTabProps) { + const [range, setRange] = useState('24h') + const [noPrometheusSince, setNoPrometheusSince] = useState(null) + const currentQuery = useOpenCostApplicationCost(workloads) + const trendQuery = useOpenCostApplicationCostTrend(workloads, range) + const trendMatchesRange = trendQuery.data?.range === range + const trendData = trendMatchesRange ? trendQuery.data : undefined + const trendLoading = trendQuery.isLoading || (trendQuery.isFetching && Boolean(trendQuery.data) && !trendMatchesRange) + const state = getApplicationCostState(currentQuery.data, trendData, { + currentLoading: currentQuery.isLoading, + trendLoading, + currentError: currentQuery.isError, + trendError: trendQuery.isError, + }) + + useEffect(() => { + if (state === 'no_prometheus') { + setNoPrometheusSince((prev) => prev ?? Date.now()) + } else { + setNoPrometheusSince(null) + } + }, [state]) + + const supportedCount = workloads.filter((w) => isOpenCostWorkloadKind(w.kind)).length + const chartSeries = useMemo(() => applicationChartSeries(trendData), [trendData]) + const workloadByKey = useMemo(() => { + const map = new Map() + for (const workload of workloads) map.set(applicationCostKey(workload), workload) + return map + }, [workloads]) + + if (supportedCount === 0) { + return + } + + if (state === 'loading') { + return ( +
+ + Loading application cost… +
+ ) + } + + if (state === 'no_prometheus' || state === 'no_metrics' || state === 'query_error' || state === 'load_error') { + const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince + if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) { + return ( + { + setNoPrometheusSince(Date.now()) + currentQuery.refetch() + trendQuery.refetch() + }} + /> + ) + } + return + } + + const current = currentQuery.data + const trend = trendData + const hasCurrent = current?.available === true && (current.coverage?.included ?? 0) > 0 + const totals = hasCurrent ? current?.totals : undefined + const coverage = current?.coverage ?? trend?.coverage + const included = coverage?.included ?? 0 + const total = coverage?.total ?? workloads.length + const unavailableCount = coverage?.unavailable?.length ?? 0 + const unsupportedCount = coverage?.unsupported?.length ?? 0 + const hourly = totals?.hourlyCost ?? 0 + const monthly = hourly * 730 + const currentSplit = (totals?.cpuCost ?? 0) + (totals?.memoryCost ?? 0) + const cpuPct = currentSplit > 0 ? ((totals?.cpuCost ?? 0) / currentSplit) * 100 : 0 + const memoryPct = currentSplit > 0 ? ((totals?.memoryCost ?? 0) / currentSplit) * 100 : 0 + const points = trend?.available ? trend.dataPoints ?? [] : [] + const hasTrend = points.length >= 2 && points.some((p) => p.value > 0) + const rows = current?.workloads ?? [] + const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0) + + return ( +
+ {(current?.partial || trend?.partial || state === 'partial_missing_history' || state === 'partial_missing_current') && ( +
+ + + Showing tracked steady-state compute for {included} of {total} workloads. + {unavailableCount > 0 ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} had no cost metrics for this window.` : ''} + {unsupportedCount > 0 ? ` ${unsupportedCount} job/batch or unsupported workload${unsupportedCount === 1 ? '' : 's'} excluded until that cost model lands.` : ''} + +
+ )} + +
+
+
+ +
+
+
Application compute cost
+ +
+
{app.name} · Deployment, StatefulSet, and DaemonSet workloads
+
+
+
+ {TIME_RANGES.map((tr) => ( + + ))} +
+
+ +
+
+ + +
+
+ {trendLoading ? ( +
+ + Loading historical cost… +
+ ) : hasTrend && chartSeries.length > 0 ? ( +
+ +
+ ) : ( +
+ No historical workload owner cost points for this range. +
+ )} +
+
+
+ +
+ 0 ? `${unsupportedCount} unsupported` : 'All supported workloads included'} /> + + +
+ +
+
+
+
+
Current split
+ +
+
Last 1h OpenCost allocation window
+
+
{totals ? formatCostPerHour(currentSplit) : '—'}
+
+
+
+ {totals && ( + <> +
+
+ + )} +
+
+
+ + +
+
+ +
+
+
+
Workload contributors
+
Current hourly allocation, sorted by spend
+
+
{rows.length} tracked
+
+ {rows.length === 0 ? ( +
No workload cost rows available for this application.
+ ) : ( +
+ {rows.map((row) => { + const appWorkload = workloadByKey.get(applicationCostKey(row)) + return ( + onSelectWorkloadCost(appWorkload) : undefined} + /> + ) + })} +
+ )} +
+ +
+ Powered by OpenCost via Prometheus. Application cost currently includes CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads; batch/job and storage attribution are handled separately. +
+
+ ) +} + +export function getApplicationCostState( + current: OpenCostApplicationCostResponse | undefined, + trend: OpenCostApplicationCostTrendResponse | undefined, + status: ApplicationCostQueryStatus, +): ApplicationCostState { + const loading = Boolean(status.currentLoading || status.trendLoading) + const queryError = Boolean(status.currentError || status.trendError) + const currentHasData = current?.available === true && (current.coverage?.included ?? 0) > 0 + const trendHasData = trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0) + if (currentHasData) { + if (status.trendLoading && !trend) return 'data' + if (status.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) return 'partial_missing_history' + if ((current?.totals?.hourlyCost ?? 0) === 0 && !trendHasData) return 'zero' + if (!trend?.available) return 'partial_missing_history' + return 'data' + } + if (trendHasData) return 'partial_missing_current' + if (queryError) return 'load_error' + if (loading) return 'loading' + + const reason = current?.reason ?? trend?.reason + if (reason === 'no_prometheus' || reason === 'query_error') return reason + return 'no_metrics' +} + +function ApplicationWorkloadCostRow({ row, maxCost, onOpen }: { row: OpenCostApplicationWorkloadCost; maxCost: number; onOpen?: () => void }) { + const current = row.current + const hourly = current?.hourlyCost ?? 0 + const monthly = hourly * 730 + const cpuPct = hourly > 0 ? ((current?.cpuCost ?? 0) / hourly) * 100 : 0 + const barWidth = maxCost > 0 ? Math.max((hourly / maxCost) * 100, 3) : 0 + const content = ( + <> +
+
+ {row.kind} + {row.name} + {row.namespace} +
+ {!row.available &&
{reasonLabel(row.reason)}
} +
+
{current ? formatCostPerHour(hourly) : '—'}
+
{current ? `~${formatCost(monthly)}/mo` : '—'}
+
+
+
+
+
+
+
+
+
+ {current ? `${formatCost(current.cpuCost)} / ${formatCost(current.memoryCost)}` : '—'} +
+ + ) + + if (!onOpen) { + return
{content}
+ } + return ( + + ) +} + +function applicationChartSeries(trend: OpenCostApplicationCostTrendResponse | undefined): OpenCostTrendSeries[] { + return (trend?.series ?? []) + .filter((series) => (series.dataPoints ?? []).length >= 2) + .map((series) => ({ + namespace: `${series.name} (${series.kind})`, + dataPoints: series.dataPoints ?? [], + })) +} + +function applicationCostKey(ref: { kind: string; namespace: string; name: string }) { + return `${ref.kind}/${ref.namespace}/${ref.name}` +} + +function reasonLabel(reason?: CostUnavailableReason) { + if (reason === 'no_prometheus') return 'Prometheus not found' + if (reason === 'query_error') return 'Cost query failed' + return 'No workload cost metrics' +} + +function ApplicationCostDiscovering({ isFetching, onRetry }: { isFetching: boolean; onRetry: () => void }) { + return ( +
+
+ +
+

Looking for Prometheus cost data…

+

First discovery can take a few seconds while Radar checks cluster services and opens a local port-forward.

+
+ +
+
+ ) +} + +function ApplicationCostUnavailable({ state, message }: { state: CostUnavailableReason | 'load_error'; message?: string }) { + const text = message ?? ( + state === 'no_prometheus' + ? 'Prometheus not found. OpenCost application cost requires Prometheus or VictoriaMetrics.' + : state === 'query_error' + ? 'Cost data is temporarily unavailable. Prometheus was found, but application cost queries failed.' + : state === 'load_error' + ? 'Could not load application cost data. Check access to these workloads and try again.' + : 'OpenCost workload metrics were not found for this application.' + ) + return ( +
+
+ +
{text}
+
+
+ ) +} + +function CostMetricBlock({ label, value, subvalue }: { label: string; value: string; subvalue?: string }) { + return ( +
+
{label}
+
{value}
+ {subvalue &&
{subvalue}
} +
+ ) +} + +function CostMetricTile({ label, value, subvalue, tooltip }: { label: string; value: string; subvalue?: string; tooltip?: string }) { + return ( +
+
+
{label}
+ {tooltip && } +
+
{value}
+ {subvalue &&
{subvalue}
} +
+ ) +} + +function CostInfoTooltip({ content }: { content: string }) { + return ( + + + + ) +} + +function CostLegendItem({ colorClass, label, value }: { colorClass: string; label: string; value: string }) { + return ( +
+ + + {label} + + {value} +
+ ) +} diff --git a/web/src/components/cost/CostTrendChart.tsx b/web/src/components/cost/CostTrendChart.tsx index f40b44da6..1876349ef 100644 --- a/web/src/components/cost/CostTrendChart.tsx +++ b/web/src/components/cost/CostTrendChart.tsx @@ -77,7 +77,7 @@ export function CostTrendChart() { ) } -function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) { +export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) { const svgRef = useRef(null) const [hoverX, setHoverX] = useState(null) diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx index 2ec66a604..815f3b33e 100644 --- a/web/src/components/cost/CostView.tsx +++ b/web/src/components/cost/CostView.tsx @@ -4,6 +4,7 @@ import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } fr import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react' import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui' import { CostTrendChart } from './CostTrendChart' +import { formatCost } from './format' import { Tooltip } from '../ui/Tooltip' import { useConnection } from '../../context/ConnectionContext' @@ -588,19 +589,3 @@ function efficiencyColor(efficiency: number): string { if (efficiency >= 25) return 'text-amber-400' return 'text-red-400' } - -function formatCost(value: number): string { - if (value >= 1000) { - return `$${(value / 1000).toFixed(1)}k` - } - if (value >= 1) { - return `$${value.toFixed(2)}` - } - if (value >= 0.01) { - return `$${value.toFixed(3)}` - } - if (value > 0) { - return `$${value.toFixed(4)}` - } - return '$0.00' -} diff --git a/web/src/components/cost/WorkloadCostTab.test.ts b/web/src/components/cost/WorkloadCostTab.test.ts index 834c2b78e..6e34ee606 100644 --- a/web/src/components/cost/WorkloadCostTab.test.ts +++ b/web/src/components/cost/WorkloadCostTab.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildLineChart, getWorkloadCostState } from './WorkloadCostTab' +import { getWorkloadCostState } from './WorkloadCostTab' +import { buildLineChart } from './chart' import type { OpenCostWorkloadDetailResponse, OpenCostWorkloadTrendResponse } from '../../api/client' describe('getWorkloadCostState', () => { diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index 14e5e21fc..39f5b858c 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -12,7 +12,8 @@ import { type OpenCostWorkloadTrendResponse, } from '../../api/client' import { Tooltip } from '../ui/Tooltip' -import { formatCostAxis } from './format' +import { buildLineChart, formatChartTime } from './chart' +import { formatCost, formatCostAxis } from './format' const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ { value: '6h', label: '6h' }, @@ -371,48 +372,3 @@ function WorkloadCostLineChart({ points }: { points: OpenCostTrendDataPoint[] })
) } - -export function buildLineChart(points: OpenCostTrendDataPoint[]) { - if (points.length < 2) return null - const width = 720 - const height = 240 - const left = 44 - const right = 16 - const top = 14 - const bottom = 28 - const plotWidth = width - left - right - const plotHeight = height - top - bottom - const minTs = points[0].timestamp - const maxTs = points[points.length - 1].timestamp - if (maxTs <= minTs) return null - const maxValue = Math.max(...points.map((p) => p.value), 0) - const yMax = maxValue > 0 ? maxValue * 1.15 : 1 - const toX = (ts: number) => left + ((ts - minTs) / (maxTs - minTs)) * plotWidth - const toY = (value: number) => top + plotHeight - (value / yMax) * plotHeight - const coords = points.map((p) => [toX(p.timestamp), toY(p.value)] as const) - const linePath = coords.map(([x, y], idx) => `${idx === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ') - const baseline = top + plotHeight - const areaPath = `${linePath} L ${coords[coords.length - 1][0].toFixed(1)} ${baseline.toFixed(1)} L ${coords[0][0].toFixed(1)} ${baseline.toFixed(1)} Z` - const yTicks = [0, 0.5, 1].map((pct) => ({ - value: yMax * pct, - y: top + plotHeight - pct * plotHeight, - })) - return { linePath, areaPath, yTicks } -} - -function formatCost(value: number) { - if (!Number.isFinite(value) || value <= 0) return '$0.00' - if (value < 0.0001) return formatCostAxis(value) - if (value < 0.01) return `$${value.toFixed(4)}` - if (value < 1) return `$${value.toFixed(3)}` - return `$${value.toFixed(2)}` -} - -function formatChartTime(timestamp?: number) { - if (!timestamp) return '' - return new Date(timestamp * 1000).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - }) -} diff --git a/web/src/components/cost/chart.ts b/web/src/components/cost/chart.ts new file mode 100644 index 000000000..8b30f3b5c --- /dev/null +++ b/web/src/components/cost/chart.ts @@ -0,0 +1,38 @@ +import type { OpenCostTrendDataPoint } from '../../api/client' + +export function buildLineChart(points: OpenCostTrendDataPoint[]) { + if (points.length < 2) return null + const width = 720 + const height = 240 + const left = 44 + const right = 16 + const top = 14 + const bottom = 28 + const plotWidth = width - left - right + const plotHeight = height - top - bottom + const minTs = points[0].timestamp + const maxTs = points[points.length - 1].timestamp + if (maxTs <= minTs) return null + const maxValue = Math.max(...points.map((p) => p.value), 0) + const yMax = maxValue > 0 ? maxValue * 1.15 : 1 + const toX = (ts: number) => left + ((ts - minTs) / (maxTs - minTs)) * plotWidth + const toY = (value: number) => top + plotHeight - (value / yMax) * plotHeight + const coords = points.map((p) => [toX(p.timestamp), toY(p.value)] as const) + const linePath = coords.map(([x, y], idx) => `${idx === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ') + const baseline = top + plotHeight + const areaPath = `${linePath} L ${coords[coords.length - 1][0].toFixed(1)} ${baseline.toFixed(1)} L ${coords[0][0].toFixed(1)} ${baseline.toFixed(1)} Z` + const yTicks = [0, 0.5, 1].map((pct) => ({ + value: yMax * pct, + y: top + plotHeight - pct * plotHeight, + })) + return { linePath, areaPath, yTicks } +} + +export function formatChartTime(timestamp?: number) { + if (!timestamp) return '' + return new Date(timestamp * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + }) +} diff --git a/web/src/components/cost/format.ts b/web/src/components/cost/format.ts index 82dd6a42a..df9c1cb90 100644 --- a/web/src/components/cost/format.ts +++ b/web/src/components/cost/format.ts @@ -7,3 +7,16 @@ export function formatCostAxis(value: number): string { if (value >= 0.00001) return `$${value.toFixed(5)}` return '<$0.00001' } + +export function formatCost(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '$0.00' + if (value >= 1000) return `$${(value / 1000).toFixed(1)}k` + if (value >= 1) return `$${value.toFixed(2)}` + if (value >= 0.01) return `$${value.toFixed(3)}` + if (value >= 0.0001) return `$${value.toFixed(4)}` + return formatCostAxis(value) +} + +export function formatCostPerHour(value: number): string { + return `${formatCost(value)}/hr` +} diff --git a/web/src/components/cost/kinds.ts b/web/src/components/cost/kinds.ts new file mode 100644 index 000000000..2f7ef8779 --- /dev/null +++ b/web/src/components/cost/kinds.ts @@ -0,0 +1,5 @@ +const OPEN_COST_WORKLOAD_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet']) + +export function isOpenCostWorkloadKind(kind: string): boolean { + return OPEN_COST_WORKLOAD_KINDS.has(kind) +} diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index f3975de67..3ed667dd8 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -43,6 +43,7 @@ import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid' import { RestartEventLane } from '../resource/RestartChart' import { RightsizingStrip } from '../resource/RightsizingStrip' import { WorkloadCostTab } from '../cost/WorkloadCostTab' +import { isOpenCostWorkloadKind } from '../cost/kinds' import { useResourceAudit, useResourceIssues, useResources } from '../../api/client' import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui' import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer' @@ -918,10 +919,6 @@ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean { const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet']) -function isOpenCostWorkloadKind(kind: string): boolean { - return WORKLOAD_LOG_KINDS.has(kind) -} - function LogsTabContent({ kind, apiKind, From 9fa8ad269c98cb3c5950164d65b2c2ef414daeea Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Fri, 10 Jul 2026 13:37:52 +0300 Subject: [PATCH 07/12] Polish application cost UX --- .../components/applications/ApplicationDetail.tsx | 6 +++++- .../components/applications/ApplicationsView.tsx | 7 ++++--- web/src/components/cost/ApplicationCostTab.tsx | 13 +++++++------ web/src/components/cost/CostTrendChart.tsx | 4 ++-- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index 0d2430be8..3142dedc1 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -501,7 +501,11 @@ function ApplicationWorkspace({ onChange={onViewChange} onCostChange={onCostViewChange} /> - {costSelected && renderCostView && renderCostView({ app, workloads, onSelectWorkload })} + {costSelected && renderCostView && ( +
+ {renderCostView({ app, workloads, onSelectWorkload })} +
+ )} {!costSelected && activeView === 'overview' && ( { - if (selectedRouteView !== 'cost' || applicationCostAvailable) return + if (singleWorkloadKey || selectedRouteView !== 'cost' || applicationCostAvailable) return const params = new URLSearchParams(searchParams) params.delete('view') setSearchParams(params, { replace: true }) - }, [applicationCostAvailable, searchParams, selectedRouteView, setSearchParams]) + }, [applicationCostAvailable, searchParams, selectedRouteView, setSearchParams, singleWorkloadKey]) const selectView = useCallback( (view: ApplicationRouteView) => { const params = new URLSearchParams(searchParams) diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index ab18e6fd2..a0eedfaa0 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -14,7 +14,7 @@ import { type OpenCostTrendSeries, } from '../../api/client' import { Tooltip } from '../ui/Tooltip' -import { StackedAreaChart } from './CostTrendChart' +import { ChartLegend, StackedAreaChart } from './CostTrendChart' import { formatCost, formatCostPerHour } from './format' import { isOpenCostWorkloadKind } from './kinds' @@ -71,7 +71,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App }, [workloads]) if (supportedCount === 0) { - return + return } if (state === 'loading') { @@ -127,12 +127,12 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App Showing tracked steady-state compute for {included} of {total} workloads. {unavailableCount > 0 ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} had no cost metrics for this window.` : ''} - {unsupportedCount > 0 ? ` ${unsupportedCount} job/batch or unsupported workload${unsupportedCount === 1 ? '' : 's'} excluded until that cost model lands.` : ''} + {unsupportedCount > 0 ? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.` : ''}
)} -
+
@@ -141,7 +141,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
Application compute cost
-
{app.name} · Deployment, StatefulSet, and DaemonSet workloads
+
OpenCost CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads
@@ -184,6 +184,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App ) : hasTrend && chartSeries.length > 0 ? (
+
) : (
@@ -264,7 +265,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
- Powered by OpenCost via Prometheus. Application cost currently includes CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads; batch/job and storage attribution are handled separately. + Powered by OpenCost via Prometheus. Application cost currently includes CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads. Batch/job cost is separate; storage/PVC and network costs remain at namespace and cluster level.
) diff --git a/web/src/components/cost/CostTrendChart.tsx b/web/src/components/cost/CostTrendChart.tsx index 1876349ef..38c9a3711 100644 --- a/web/src/components/cost/CostTrendChart.tsx +++ b/web/src/components/cost/CostTrendChart.tsx @@ -251,7 +251,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) key={`xlabel-${i}`} x={tick.x} y={height - 4} - textAnchor="middle" + textAnchor={i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'} className="fill-theme-text-secondary" fontSize="10" fontFamily="ui-monospace, monospace" @@ -345,7 +345,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) ) } -function ChartLegend({ series }: { series: OpenCostTrendSeries[] }) { +export function ChartLegend({ series }: { series: OpenCostTrendSeries[] }) { return (
{series.map((s, i) => ( From ae6ac0cf13bb9a49c0f80830df33fef0214203f1 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Fri, 10 Jul 2026 22:50:37 +0300 Subject: [PATCH 08/12] Make cost views monthly-first --- .../components/cost/ApplicationCostTab.tsx | 179 ++++++++++--- web/src/components/cost/CostTrendChart.tsx | 117 +++++---- web/src/components/cost/CostView.tsx | 243 ++++++++++-------- web/src/components/cost/WorkloadCostTab.tsx | 112 +++++--- web/src/components/cost/format.test.ts | 19 ++ web/src/components/cost/format.ts | 19 ++ web/src/components/home/CostCard.tsx | 57 ++-- 7 files changed, 482 insertions(+), 264 deletions(-) create mode 100644 web/src/components/cost/format.test.ts diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index a0eedfaa0..263dbf3cf 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -15,7 +15,13 @@ import { } from '../../api/client' import { Tooltip } from '../ui/Tooltip' import { ChartLegend, StackedAreaChart } from './CostTrendChart' -import { formatCost, formatCostPerHour } from './format' +import { + formatCost, + formatCostPerHour, + formatProjectedDailyRate, + formatProjectedMonthlyCost, + formatProjectedMonthlyRate, +} from './format' import { isOpenCostWorkloadKind } from './kinds' const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ @@ -24,7 +30,14 @@ const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ { value: '7d', label: '7d' }, ] -type ApplicationCostState = 'loading' | 'data' | 'partial_missing_history' | 'partial_missing_current' | 'zero' | 'load_error' | CostUnavailableReason +type ApplicationCostState = + | 'loading' + | 'data' + | 'partial_missing_history' + | 'partial_missing_current' + | 'zero' + | 'load_error' + | CostUnavailableReason interface ApplicationCostQueryStatus { currentLoading?: boolean @@ -71,7 +84,12 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App }, [workloads]) if (supportedCount === 0) { - return + return ( + + ) } if (state === 'loading') { @@ -110,29 +128,38 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App const unavailableCount = coverage?.unavailable?.length ?? 0 const unsupportedCount = coverage?.unsupported?.length ?? 0 const hourly = totals?.hourlyCost ?? 0 - const monthly = hourly * 730 const currentSplit = (totals?.cpuCost ?? 0) + (totals?.memoryCost ?? 0) const cpuPct = currentSplit > 0 ? ((totals?.cpuCost ?? 0) / currentSplit) * 100 : 0 const memoryPct = currentSplit > 0 ? ((totals?.memoryCost ?? 0) / currentSplit) * 100 : 0 - const points = trend?.available ? trend.dataPoints ?? [] : [] + const points = trend?.available ? (trend.dataPoints ?? []) : [] const hasTrend = points.length >= 2 && points.some((p) => p.value > 0) const rows = current?.workloads ?? [] const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0) return (
- {(current?.partial || trend?.partial || state === 'partial_missing_history' || state === 'partial_missing_current') && ( + {(current?.partial || + trend?.partial || + state === 'partial_missing_history' || + state === 'partial_missing_current') && (
Showing tracked steady-state compute for {included} of {total} workloads. - {unavailableCount > 0 ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} had no cost metrics for this window.` : ''} - {unsupportedCount > 0 ? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.` : ''} + {unavailableCount > 0 + ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} had no cost metrics for this window.` + : ''} + {unsupportedCount > 0 + ? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.` + : ''}
)} -
+
@@ -141,7 +168,9 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
Application compute cost
-
OpenCost CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads
+
+ OpenCost CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads +
@@ -166,13 +195,23 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
@@ -196,17 +235,25 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
- 0 ? `${unsupportedCount} unsupported` : 'All supported workloads included'} /> + 0 ? `${unsupportedCount} unsupported` : 'All supported workloads included'} + />
@@ -219,7 +266,9 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
Last 1h OpenCost allocation window
-
{totals ? formatCostPerHour(currentSplit) : '—'}
+
+ {totals ? formatCostPerHour(currentSplit) : '—'} +
@@ -232,8 +281,16 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
- - + +
@@ -241,12 +298,16 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
Workload contributors
-
Current hourly allocation, sorted by spend
+
+ Projected monthly from current allocation, sorted by spend +
{rows.length} tracked
{rows.length === 0 ? ( -
No workload cost rows available for this application.
+
+ No workload cost rows available for this application. +
) : (
{rows.map((row) => { @@ -265,7 +326,9 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App
- Powered by OpenCost via Prometheus. Application cost currently includes CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads. Batch/job cost is separate; storage/PVC and network costs remain at namespace and cluster level. + Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected monthly values multiply + current hourly allocation. Batch/job cost is separate; storage/PVC and network costs remain at namespace and + cluster level.
) @@ -282,7 +345,8 @@ export function getApplicationCostState( const trendHasData = trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0) if (currentHasData) { if (status.trendLoading && !trend) return 'data' - if (status.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) return 'partial_missing_history' + if (status.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) + return 'partial_missing_history' if ((current?.totals?.hourlyCost ?? 0) === 0 && !trendHasData) return 'zero' if (!trend?.available) return 'partial_missing_history' return 'data' @@ -296,24 +360,37 @@ export function getApplicationCostState( return 'no_metrics' } -function ApplicationWorkloadCostRow({ row, maxCost, onOpen }: { row: OpenCostApplicationWorkloadCost; maxCost: number; onOpen?: () => void }) { +function ApplicationWorkloadCostRow({ + row, + maxCost, + onOpen, +}: { + row: OpenCostApplicationWorkloadCost + maxCost: number + onOpen?: () => void +}) { const current = row.current const hourly = current?.hourlyCost ?? 0 - const monthly = hourly * 730 const cpuPct = hourly > 0 ? ((current?.cpuCost ?? 0) / hourly) * 100 : 0 const barWidth = maxCost > 0 ? Math.max((hourly / maxCost) * 100, 3) : 0 const content = ( <>
- {row.kind} + + {row.kind} + {row.name} {row.namespace}
{!row.available &&
{reasonLabel(row.reason)}
}
-
{current ? formatCostPerHour(hourly) : '—'}
-
{current ? `~${formatCost(monthly)}/mo` : '—'}
+
+ {current ? formatProjectedMonthlyRate(hourly) : '—'} +
+
+ {current ? formatCostPerHour(hourly) : '—'} +
@@ -329,7 +406,11 @@ function ApplicationWorkloadCostRow({ row, maxCost, onOpen }: { row: OpenCostApp ) if (!onOpen) { - return
{content}
+ return ( +
+ {content} +
+ ) } return ( {reason === 'no_prometheus' && ( @@ -97,7 +102,6 @@ export function CostView({ onBack }: CostViewProps) { } const hourlyCost = data.totalHourlyCost ?? 0 - const monthlyCost = hourlyCost * 730 const namespaces = data.namespaces ?? [] const totalCpu = namespaces.reduce((sum, ns) => sum + ns.cpuCost, 0) const totalMem = namespaces.reduce((sum, ns) => sum + ns.memoryCost, 0) @@ -111,7 +115,7 @@ export function CostView({ onBack }: CostViewProps) { const memPct = allocTotal > 0 ? (totalMem / allocTotal) * 100 : 50 const storagePct = allocTotal > 0 ? (totalStorage / allocTotal) * 100 : 0 - const nodes = nodeData?.available ? nodeData.nodes ?? [] : [] + const nodes = nodeData?.available ? (nodeData.nodes ?? []) : [] return (
@@ -141,7 +145,7 @@ export function CostView({ onBack }: CostViewProps) {
- {/* Tracks the headline $/hr summary (the primary query); its load + {/* Tracks the headline monthly summary (the primary query); its load time is the representative freshness signal for the view. */}
- ~{formatCost((data.totalIdleCost ?? 0) * 730)}/mo unused capacity + {formatProjectedMonthlyRate(data.totalIdleCost ?? 0)} unused capacity
)}
-
-
- - {formatCost(hourlyCost)} - - /hr -
-
- ~{formatCost(monthlyCost)} - /mo -
+
+ + {formatProjectedMonthlyCost(hourlyCost)} + + /mo +
+
+ {formatProjectedDailyRate(hourlyCost)} + · + {formatCostPerHour(hourlyCost)}
- based on last 1h average + projected from last 1h average
@@ -186,34 +189,25 @@ export function CostView({ onBack }: CostViewProps) {
- CPU {formatCost(totalCpu)}/hr + CPU {formatCostPerHour(totalCpu)} - Memory {formatCost(totalMem)}/hr + Memory {formatCostPerHour(totalMem)} {hasStorage && ( - Storage {formatCost(totalStorage)}/hr + Storage {formatCostPerHour(totalStorage)} )}
-
-
+
+
{hasStorage && ( -
+
)}
@@ -227,21 +221,27 @@ export function CostView({ onBack }: CostViewProps) {
Namespace Breakdown - current hourly rates + projected monthly from current rate
{namespaces.length} namespaces
{/* Table header */} -
+
Namespace - Hourly - - Monthly* + + Projected/mo* - - Efficiency + Hourly + + Efficiency CPU / Memory Cost Split @@ -250,7 +250,12 @@ export function CostView({ onBack }: CostViewProps) { {/* Namespace rows */}
{namespaces.map((ns) => ( - + ))}
@@ -261,7 +266,8 @@ export function CostView({ onBack }: CostViewProps) { {/* Footer */}
- {data.currency ?? 'USD'} · costs based on last 1h average · *monthly estimates assume 730 hrs/mo + {data.currency ?? 'USD'} · current rates based on last 1h average · *monthly projections + assume {COST_HOURS_PER_MONTH} hrs/mo Powered by OpenCost
@@ -273,9 +279,16 @@ export function CostView({ onBack }: CostViewProps) { ) } -function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCost; maxCost: number; hasStorage: boolean }) { +function NamespaceCostRow({ + ns, + maxCost, + hasStorage, +}: { + ns: OpenCostNamespaceCost + maxCost: number + hasStorage: boolean +}) { const [expanded, setExpanded] = useState(false) - const monthlyCost = ns.hourlyCost * 730 const allocTotal = ns.cpuCost + ns.memoryCost + (ns.storageCost ?? 0) const cpuPct = allocTotal > 0 ? (ns.cpuCost / allocTotal) * 100 : 50 const memPct = allocTotal > 0 ? (ns.memoryCost / allocTotal) * 100 : 50 @@ -287,7 +300,7 @@ function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCo
{/* Expanded workload rows */} - {expanded && ( - - )} + {expanded && }
) } @@ -365,7 +380,6 @@ function WorkloadRows({ namespace }: { namespace: string }) { } function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: number }) { - const monthlyCost = wl.hourlyCost * 730 const cpuPct = wl.hourlyCost > 0 ? (wl.cpuCost / wl.hourlyCost) * 100 : 50 const barWidth = maxCost > 0 ? (wl.hourlyCost / maxCost) * 100 : 0 const eff = wl.efficiency ?? 0 @@ -373,22 +387,24 @@ function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: n const kindLabel = wl.kind === 'standalone' ? 'pod' : wl.kind return ( -
+
- {kindLabel} + + {kindLabel} + {wl.name} - {wl.replicas > 1 && ( - {wl.replicas}x - )} + {wl.replicas > 1 && {wl.replicas}x} + + + {formatProjectedMonthlyCost(wl.hourlyCost)} + + + {formatCostPerHour(wl.hourlyCost)} - {formatCost(wl.hourlyCost)} - ~{formatCost(monthlyCost)} {hasEff ? ( <> - - {eff.toFixed(0)}% - + {eff.toFixed(0)}% {eff < 25 && low} ) : ( @@ -396,7 +412,10 @@ function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: n )} -
+
@@ -428,13 +447,16 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) {
{/* Table header */} -
+
Node Instance Type - Hourly - - Monthly* + + Projected/mo* + Hourly CPU / Memory
@@ -449,21 +471,21 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) { } function NodeCostRow({ node }: { node: OpenCostNodeCost }) { - const monthlyCost = node.hourlyCost * 730 - return ( -
+
- - {node.name} - + {node.name} {node.instanceType || '-'} {node.region && ({node.region})} - {formatCost(node.hourlyCost)} - ~{formatCost(monthlyCost)} + + {formatProjectedMonthlyCost(node.hourlyCost)} + + + {formatCostPerHour(node.hourlyCost)} + {formatCost(node.cpuCost)} / {formatCost(node.memoryCost)} @@ -506,22 +528,25 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {

Where do these costs come from?

Cost data comes from OpenCost, an open-source tool that combines your cloud provider's - pricing (how much each node costs per hour) with Kubernetes resource allocation data. This gives you - a dollar value for each workload running on your cluster. + pricing (how much each node costs per hour) with Kubernetes resource allocation data. This gives you a + dollar value for each workload running on your cluster.

{/* What costs represent */}
-

What does "hourly cost" mean?

+

+ What do monthly, daily, and hourly cost mean? +

- Each workload requests a certain amount of CPU and memory when it's deployed. - These requests reserve capacity on a node — that reserved capacity has a cost based on - the node's cloud pricing, whether the workload actually uses it or not. + Each workload requests a certain amount of CPU and memory when it's deployed. These + requests reserve capacity on a node — that reserved capacity has a cost based on the node's cloud pricing, + whether the workload actually uses it or not.

- The hourly cost shown here is based on what your workloads have reserved (requested), - not what they're actually consuming. Monthly estimates simply multiply the current hourly rate by 730 hours. + Projected monthly and daily numbers multiply the current hourly allocation rate. They are useful for + budget impact, but they are not a historical invoice total. Historical spend on application and workload + tabs uses the selected range.

@@ -529,26 +554,32 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {

What is efficiency?

- Efficiency compares what you're actually using versus what you've reserved, - weighted by cost. If a namespace reserves $1/hr of resources but only uses $0.40 worth, it's 40% efficient — - the other $0.60/hr is idle capacity you're paying for but not using. + Efficiency compares what you're actually using versus what you've{' '} + reserved, weighted by cost. If a namespace reserves $1/hr of resources but only uses + $0.40 worth, it's 40% efficient — the other $0.60/hr is idle capacity you're paying for but not using.

- Some over-provisioning is normal and healthy — it gives your workloads room to handle - traffic spikes without running out of resources. Don't aim for 100%. + Some over-provisioning is normal and healthy — it gives your workloads room to handle traffic spikes + without running out of resources. Don't aim for 100%.

- 50%+ — well-utilized + + 50%+ — well-utilized +
- 25–50% — typical for most clusters, some room to optimize + + 25–50% — typical for most clusters, some room to optimize +
- Below 25% — worth investigating, may be significantly over-provisioned + + Below 25% — worth investigating, may be significantly over-provisioned +
@@ -558,12 +589,12 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {

How fresh is this data?

Cost rates, efficiency, and breakdowns are snapshots based on the last 1 hour of data. - They update automatically every minute. The trend chart is the only historical view — it shows how - total cost has changed over the selected time range (6 hours, 24 hours, or 7 days). + They update automatically every minute. The trend chart shows historical hourly allocation rate over the + selected time range (6 hours, 24 hours, or 7 days).

- Because costs are based on a 1-hour window, short-lived spikes or dips may not be reflected. - The trend chart gives you the longer-term picture. + Because costs are based on a 1-hour window, short-lived spikes or dips may not be reflected. The trend + chart gives you the longer-term rate picture.

@@ -571,9 +602,9 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {

What are node costs?

- Node costs show the hourly price of each machine in your cluster, based on instance type and - cloud pricing. This is the total capacity cost — the namespace and workload breakdowns above - show how that capacity is allocated across your workloads. + Node costs show the hourly price of each machine in your cluster, based on instance type and cloud + pricing. This is the total capacity cost — the namespace and workload breakdowns above show how that + capacity is allocated across your workloads.

diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index 39f5b858c..d5b375645 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -13,7 +13,14 @@ import { } from '../../api/client' import { Tooltip } from '../ui/Tooltip' import { buildLineChart, formatChartTime } from './chart' -import { formatCost, formatCostAxis } from './format' +import { + formatCost, + formatCostAxis, + formatCostPerHour, + formatProjectedDailyRate, + formatProjectedMonthlyCost, + formatProjectedMonthlyRate, +} from './format' const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ { value: '6h', label: '6h' }, @@ -21,7 +28,14 @@ const TIME_RANGES: { value: CostTimeRange; label: string }[] = [ { value: '7d', label: '7d' }, ] -type WorkloadCostState = 'loading' | 'data' | 'partial_missing_history' | 'partial_missing_current' | 'zero' | 'load_error' | CostUnavailableReason +type WorkloadCostState = + | 'loading' + | 'data' + | 'partial_missing_history' + | 'partial_missing_current' + | 'zero' + | 'load_error' + | CostUnavailableReason interface WorkloadCostQueryStatus { currentLoading?: boolean @@ -88,12 +102,11 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) const current = currentQuery.data?.current const trend = trendData - const points = trend?.available ? trend.dataPoints ?? [] : [] + const points = trend?.available ? (trend.dataPoints ?? []) : [] const hasTrend = points.length >= 2 && points.some((p) => p.value > 0) const hasCurrent = Boolean(current) const hourly = current?.hourlyCost ?? 0 - const monthly = hourly * 730 - const windowTotal = trend?.available ? trend.windowTotalCost ?? 0 : 0 + const windowTotal = trend?.available ? (trend.windowTotalCost ?? 0) : 0 const cpuCost = current?.cpuCost ?? 0 const memoryCost = current?.memoryCost ?? 0 const splitTotal = cpuCost + memoryCost @@ -114,11 +127,11 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
Historical compute cost
- + +
+
+ CPU and memory allocation attributed by workload ownership
-
CPU and memory allocation attributed by workload ownership
@@ -147,9 +160,9 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) subvalue={state === 'partial_missing_history' ? 'Historical data unavailable' : undefined} />
@@ -172,7 +185,9 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) {state === 'partial_missing_history' && (
- Current cost is available, but historical workload owner metrics are not available for this range. + + Current cost is available, but historical workload owner metrics are not available for this range. +
)} {state === 'partial_missing_current' && ( @@ -187,13 +202,17 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
@@ -206,7 +225,9 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
Last 1h OpenCost allocation window
-
{hasCurrent ? `${formatCost(splitTotal)}/hr` : '—'}
+
+ {hasCurrent ? formatCostPerHour(splitTotal) : '—'} +
@@ -219,13 +240,18 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
- - + +
- Powered by OpenCost via Prometheus. Workload cost currently includes CPU and memory allocation; storage/PVC attribution remains at namespace and cluster level. + Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected monthly values multiply + the current hourly allocation. Storage/PVC attribution remains at namespace and cluster level.
) @@ -236,9 +262,8 @@ export function getWorkloadCostState( trend: OpenCostWorkloadTrendResponse | undefined, status: boolean | WorkloadCostQueryStatus, ): WorkloadCostState { - const queryStatus: WorkloadCostQueryStatus = typeof status === 'boolean' - ? { currentLoading: status, trendLoading: status } - : status + const queryStatus: WorkloadCostQueryStatus = + typeof status === 'boolean' ? { currentLoading: status, trendLoading: status } : status const loading = Boolean(queryStatus.currentLoading || queryStatus.trendLoading) const queryError = Boolean(queryStatus.currentError || queryStatus.trendError) @@ -246,7 +271,8 @@ export function getWorkloadCostState( const trendHasData = trend?.available === true && (trend.dataPoints ?? []).some((p) => p.value > 0) if (currentRow) { if (queryStatus.trendLoading && !trend) return 'data' - if (queryStatus.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) return 'partial_missing_history' + if (queryStatus.trendError || (trend?.available === false && trend.reason !== 'no_metrics')) + return 'partial_missing_history' if (currentRow.hourlyCost === 0 && currentRow.replicas === 0 && !trendHasData) return 'zero' if (!trend?.available) return 'partial_missing_history' return 'data' @@ -284,13 +310,14 @@ function WorkloadCostDiscovering({ isFetching, onRetry }: { isFetching: boolean; } function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'load_error' }) { - const message = state === 'no_prometheus' - ? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.' - : state === 'query_error' - ? 'Cost data is temporarily unavailable. Prometheus was found, but workload cost queries failed.' - : state === 'load_error' - ? 'Could not load workload cost data. Check access to this workload and try again.' - : 'OpenCost workload metrics were not found for this workload.' + const message = + state === 'no_prometheus' + ? 'Prometheus not found. OpenCost workload cost requires Prometheus or VictoriaMetrics.' + : state === 'query_error' + ? 'Cost data is temporarily unavailable. Prometheus was found, but workload cost queries failed.' + : state === 'load_error' + ? 'Could not load workload cost data. Check access to this workload and try again.' + : 'OpenCost workload metrics were not found for this workload.' return (
@@ -312,7 +339,17 @@ function MetricBlock({ label, value, subvalue }: { label: string; value: string; ) } -function MetricTile({ label, value, subvalue, tooltip }: { label: string; value: string; subvalue?: string; tooltip?: string }) { +function MetricTile({ + label, + value, + subvalue, + tooltip, +}: { + label: string + value: string + subvalue?: string + tooltip?: string +}) { return (
@@ -361,7 +398,14 @@ function WorkloadCostLineChart({ points }: { points: OpenCostTrendDataPoint[] }) ))} - + {formatChartTime(points[0]?.timestamp)} diff --git a/web/src/components/cost/format.test.ts b/web/src/components/cost/format.test.ts new file mode 100644 index 000000000..0681b8a7d --- /dev/null +++ b/web/src/components/cost/format.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + formatCostPerHour, + formatProjectedDailyRate, + formatProjectedMonthlyCost, + formatProjectedMonthlyRate, +} from './format' + +describe('cost formatters', () => { + it('formats projected run rates from hourly allocation', () => { + expect(formatProjectedDailyRate(0.1)).toBe('~$2.40/day') + expect(formatProjectedMonthlyCost(1)).toBe('~$730.00') + expect(formatProjectedMonthlyRate(0.1)).toBe('~$73.00/mo') + }) + + it('keeps hourly rates explicit', () => { + expect(formatCostPerHour(0.1)).toBe('$0.100/hr') + }) +}) diff --git a/web/src/components/cost/format.ts b/web/src/components/cost/format.ts index df9c1cb90..3330d6aec 100644 --- a/web/src/components/cost/format.ts +++ b/web/src/components/cost/format.ts @@ -1,3 +1,6 @@ +export const COST_HOURS_PER_DAY = 24 +export const COST_HOURS_PER_MONTH = 730 + export function formatCostAxis(value: number): string { if (!Number.isFinite(value) || value <= 0) return '$0' if (value >= 1000) return `$${(value / 1000).toFixed(0)}k` @@ -20,3 +23,19 @@ export function formatCost(value: number): string { export function formatCostPerHour(value: number): string { return `${formatCost(value)}/hr` } + +export function formatProjectedDailyCost(hourlyCost: number): string { + return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY)}` +} + +export function formatProjectedDailyRate(hourlyCost: number): string { + return `${formatProjectedDailyCost(hourlyCost)}/day` +} + +export function formatProjectedMonthlyCost(hourlyCost: number): string { + return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH)}` +} + +export function formatProjectedMonthlyRate(hourlyCost: number): string { + return `${formatProjectedMonthlyCost(hourlyCost)}/mo` +} diff --git a/web/src/components/home/CostCard.tsx b/web/src/components/home/CostCard.tsx index c573f46c0..c1e05a273 100644 --- a/web/src/components/home/CostCard.tsx +++ b/web/src/components/home/CostCard.tsx @@ -1,6 +1,12 @@ import type { OpenCostSummary } from '../../api/client' import { useOpenCostSummary } from '../../api/client' import { DollarSign } from 'lucide-react' +import { + formatCostPerHour, + formatProjectedDailyRate, + formatProjectedMonthlyCost, + formatProjectedMonthlyRate, +} from '../cost/format' export function CostCard({ onNavigate }: { onNavigate?: () => void }) { const { data } = useOpenCostSummary() @@ -15,7 +21,6 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) { function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) { const hourlyCost = data.totalHourlyCost ?? 0 - const monthlyCost = hourlyCost * 730 const namespaces = data.namespaces ?? [] const topNamespaces = namespaces.slice(0, 5) @@ -30,10 +35,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
- - Cost Insights + + Cost Insights {namespaces.length > 0 && ( - + {namespaces.length} ns )} @@ -45,13 +50,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
- {formatCost(hourlyCost)} + {formatProjectedMonthlyCost(hourlyCost)} - /hr + /mo
-
- ~{formatCost(monthlyCost)} - /mo +
+ {formatProjectedDailyRate(hourlyCost)} + · + {formatCostPerHour(hourlyCost)}
@@ -63,30 +69,25 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
{ns.name}
-
+
- - {formatCost(ns.hourlyCost)}/h + + {formatProjectedMonthlyRate(ns.hourlyCost)}
) })} {namespaces.length > 5 && ( - - +{namespaces.length - 5} more namespaces - + +{namespaces.length - 5} more namespaces )}
- {data.currency ?? 'USD'} · {data.window ?? '1h'} window + {data.currency ?? 'USD'} · projected monthly from {data.window ?? '1h'} window - + OpenCost
@@ -94,19 +95,3 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
) } - -function formatCost(value: number): string { - if (value >= 1000) { - return `$${(value / 1000).toFixed(1)}k` - } - if (value >= 1) { - return `$${value.toFixed(2)}` - } - if (value >= 0.01) { - return `$${value.toFixed(3)}` - } - if (value > 0) { - return `$${value.toFixed(4)}` - } - return '$0.00' -} From 1d30072bf9a44f525097dc7fb20d8df77f2ee362 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Fri, 10 Jul 2026 23:43:57 +0300 Subject: [PATCH 09/12] Polish cost rows and static pod costs --- pkg/opencost/types.go | 2 +- pkg/opencost/workloads.go | 2 + pkg/opencost/workloads_test.go | 30 ++++- web/src/App.tsx | 2 +- .../components/cost/ApplicationCostTab.tsx | 10 +- web/src/components/cost/CostView.tsx | 118 ++++++++++++++---- web/src/components/cost/WorkloadCostTab.tsx | 2 +- 7 files changed, 134 insertions(+), 32 deletions(-) diff --git a/pkg/opencost/types.go b/pkg/opencost/types.go index 27903bd33..50d1aec78 100644 --- a/pkg/opencost/types.go +++ b/pkg/opencost/types.go @@ -61,7 +61,7 @@ type WorkloadCostDetailResponse struct { // WorkloadCost holds per-workload cost breakdown within a namespace. type WorkloadCost struct { Name string `json:"name"` - Kind string `json:"kind"` // Deployment, StatefulSet, DaemonSet, Job, standalone + Kind string `json:"kind"` // Deployment, StatefulSet, DaemonSet, Job, standalone, staticpod HourlyCost float64 `json:"hourlyCost"` CPUCost float64 `json:"cpuCost"` MemoryCost float64 `json:"memoryCost"` diff --git a/pkg/opencost/workloads.go b/pkg/opencost/workloads.go index fe1279a5d..8072333aa 100644 --- a/pkg/opencost/workloads.go +++ b/pkg/opencost/workloads.go @@ -121,6 +121,8 @@ func ComputeWorkloadsFromProm(ctx context.Context, client *prom.Client, namespac } if !ok { owner = WorkloadOwner{Name: stripPodSuffix(podName), Kind: "standalone"} + } else if owner.Kind == "Node" { + owner = WorkloadOwner{Name: podName, Kind: "staticpod"} } wl, exists := workloadMap[owner] diff --git a/pkg/opencost/workloads_test.go b/pkg/opencost/workloads_test.go index 79c317a58..64378c56a 100644 --- a/pkg/opencost/workloads_test.go +++ b/pkg/opencost/workloads_test.go @@ -146,6 +146,28 @@ func TestComputeWorkloads_OwnerLookupUnresolvedPodFallsBack(t *testing.T) { } } +func TestComputeWorkloads_NodeOwnedPodIsStaticPodCost(t *testing.T) { + client := workloadsProm(t, podVectorBody(map[string]float64{ + "kube-apiserver-gke-node-1": 1.0, + })) + lookup := func(pod string) (WorkloadOwner, bool) { + if pod == "kube-apiserver-gke-node-1" { + return WorkloadOwner{Name: "gke-node-1", Kind: "Node"}, true + } + return WorkloadOwner{}, false + } + got := ComputeWorkloadsFromProm(context.Background(), client, "kube-system", lookup) + if !got.Available { + t.Fatalf("expected Available=true, got %+v", got) + } + if len(got.Workloads) != 1 { + t.Fatalf("expected 1 workload, got %d: %+v", len(got.Workloads), got.Workloads) + } + if got.Workloads[0].Name != "kube-apiserver-gke-node-1" || got.Workloads[0].Kind != "staticpod" { + t.Errorf("got %s/%s, want kube-apiserver-gke-node-1/staticpod", got.Workloads[0].Name, got.Workloads[0].Kind) + } +} + func TestComputeWorkloads_EmptyResultReturnsNoMetricsReason(t *testing.T) { // Queries succeed but return zero series — should surface ReasonNoMetrics // (not Available=true with empty workloads list). @@ -174,11 +196,11 @@ func TestStripPodSuffix(t *testing.T) { cases := []struct { in, want string }{ - {"myapp-7f8d9c-xyz12", "myapp"}, // deployment pod (rs-hash + pod-hash) - {"myapp-xyz12", "myapp"}, // single suffix (e.g. CronJob) + {"myapp-7f8d9c-xyz12", "myapp"}, // deployment pod (rs-hash + pod-hash) + {"myapp-xyz12", "myapp"}, // single suffix (e.g. CronJob) {"mywf-step-1-abc12-xyz", "mywf-step-1"}, // multi-segment workflow name - {"plain", "plain"}, // no dashes - {"-leading", "-leading"}, // leading-dash edge case + {"plain", "plain"}, // no dashes + {"-leading", "-leading"}, // leading-dash edge case } for _, tc := range cases { got := stripPodSuffix(tc.in) diff --git a/web/src/App.tsx b/web/src/App.tsx index e112846f0..41c216ca1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2183,7 +2183,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL {/* Cost detail view */} {mainView === 'cost' && ( - setMainView('home')} /> + setMainView('home')} onOpenResource={navigateToResource} /> )} {/* Takeover splash. When the host claims the current view via diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index 263dbf3cf..71f3386f2 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -137,7 +137,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0) return ( -
+
{(current?.partial || trend?.partial || state === 'partial_missing_history' || @@ -380,8 +380,12 @@ function ApplicationWorkloadCostRow({ {row.kind} - {row.name} - {row.namespace} + + {row.name} + + + {row.namespace} +
{!row.available &&
{reasonLabel(row.reason)}
}
diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx index ece3652ae..9f548bbbe 100644 --- a/web/src/components/cost/CostView.tsx +++ b/web/src/components/cost/CostView.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { COST_DISCOVERY_GRACE_MS, useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client' import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client' -import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react' +import { ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react' import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui' import { CostTrendChart } from './CostTrendChart' import { @@ -14,12 +14,15 @@ import { } from './format' import { Tooltip } from '../ui/Tooltip' import { useConnection } from '../../context/ConnectionContext' +import type { SelectedResource } from '../../types' +import { kindToPlural } from '../../utils/navigation' interface CostViewProps { onBack: () => void + onOpenResource?: (resource: SelectedResource) => void } -export function CostView({ onBack }: CostViewProps) { +export function CostView({ onBack, onOpenResource }: CostViewProps) { const { data, isLoading, isFetching, dataUpdatedAt, refetch } = useOpenCostSummary() const { data: nodeData } = useOpenCostNodes() const { connection } = useConnection() @@ -119,18 +122,10 @@ export function CostView({ onBack }: CostViewProps) { return (
-
+
{/* Header */}
- -

Cost Insights

@@ -255,6 +250,7 @@ export function CostView({ onBack }: CostViewProps) { ns={ns} maxCost={namespaces[0]?.hourlyCost ?? 0} hasStorage={hasStorage} + onOpenResource={onOpenResource} /> ))}
@@ -283,10 +279,12 @@ function NamespaceCostRow({ ns, maxCost, hasStorage, + onOpenResource, }: { ns: OpenCostNamespaceCost maxCost: number hasStorage: boolean + onOpenResource?: (resource: SelectedResource) => void }) { const [expanded, setExpanded] = useState(false) const allocTotal = ns.cpuCost + ns.memoryCost + (ns.storageCost ?? 0) @@ -308,7 +306,9 @@ function NamespaceCostRow({ ) : ( )} - {ns.name} + + {ns.name} + {formatProjectedMonthlyCost(ns.hourlyCost)} @@ -344,12 +344,18 @@ function NamespaceCostRow({ {/* Expanded workload rows */} - {expanded && } + {expanded && }
) } -function WorkloadRows({ namespace }: { namespace: string }) { +function WorkloadRows({ + namespace, + onOpenResource, +}: { + namespace: string + onOpenResource?: (resource: SelectedResource) => void +}) { const { data, isLoading } = useOpenCostWorkloads(namespace) if (isLoading) { @@ -373,26 +379,45 @@ function WorkloadRows({ namespace }: { namespace: string }) { return (
{workloads.map((wl) => ( - + ))}
) } -function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: number }) { +function WorkloadCostRow({ + wl, + namespace, + maxCost, + onOpenResource, +}: { + wl: OpenCostWorkloadCost + namespace: string + maxCost: number + onOpenResource?: (resource: SelectedResource) => void +}) { const cpuPct = wl.hourlyCost > 0 ? (wl.cpuCost / wl.hourlyCost) * 100 : 50 const barWidth = maxCost > 0 ? (wl.hourlyCost / maxCost) * 100 : 0 const eff = wl.efficiency ?? 0 const hasEff = eff > 0 - const kindLabel = wl.kind === 'standalone' ? 'pod' : wl.kind - - return ( -
+ const kindLabel = displayCostWorkloadKind(wl.kind) + const resource = costWorkloadResource(wl, namespace) + const canOpen = Boolean(resource && onOpenResource) + const content = ( + <> {kindLabel} - {wl.name} + + {wl.name} + {wl.replicas > 1 && {wl.replicas}x} @@ -423,8 +448,26 @@ function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: n {formatCost(wl.cpuCost)} / {formatCost(wl.memoryCost)} -
+ ) + + const rowClass = + 'grid grid-cols-[minmax(180px,1fr)_100px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2 text-left' + + if (canOpen && resource) { + return ( + + ) + } + + return
{content}
} function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) { @@ -493,6 +536,37 @@ function NodeCostRow({ node }: { node: OpenCostNodeCost }) { ) } +function displayCostWorkloadKind(kind: string): string { + if (kind === 'standalone') return 'pod' + if (kind === 'staticpod') return 'static pod' + return kind +} + +function costWorkloadResource(wl: OpenCostWorkloadCost, namespace: string): SelectedResource | null { + const kind = resourceKindForCostWorkload(wl.kind) + if (!kind || !wl.name) return null + return { + kind: kindToPlural(kind), + namespace: kind === 'Node' ? '' : namespace, + name: wl.name, + group: apiGroupForCostWorkload(kind), + } +} + +function resourceKindForCostWorkload(kind: string): string | null { + if (kind === 'standalone' || kind === 'staticpod') return 'Pod' + if (kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet') return kind + if (kind === 'Job' || kind === 'CronJob') return kind + if (kind === 'Node') return kind + return null +} + +function apiGroupForCostWorkload(kind: string): string | undefined { + if (kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet') return 'apps' + if (kind === 'Job' || kind === 'CronJob') return 'batch' + return undefined +} + // --- Help dialog --- function CostHelpDialog({ onClose }: { onClose: () => void }) { diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index d5b375645..655d17781 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -119,7 +119,7 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) : formatCost(0) return ( -
+
From 03cff90eb0fc764f95754009c73ac06ef8e115c4 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sat, 11 Jul 2026 00:07:44 +0300 Subject: [PATCH 10/12] Add cloud console links to cost nodes --- internal/opencost/handlers.go | 27 ++++++- pkg/opencost/types.go | 1 + web/src/api/client.ts | 1 + .../components/cost/ApplicationCostTab.tsx | 2 +- web/src/components/cost/CostView.tsx | 81 ++++++++++++++++--- web/src/components/cost/WorkloadCostTab.tsx | 2 +- web/src/components/cost/cloud-console.test.ts | 39 +++++++++ web/src/components/cost/cloud-console.ts | 81 +++++++++++++++++++ 8 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 web/src/components/cost/cloud-console.test.ts create mode 100644 web/src/components/cost/cloud-console.ts diff --git a/internal/opencost/handlers.go b/internal/opencost/handlers.go index d89009640..9f2b627b4 100644 --- a/internal/opencost/handlers.go +++ b/internal/opencost/handlers.go @@ -143,5 +143,30 @@ func handleNodes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus}) return } - writeJSON(w, http.StatusOK, pkgopencost.ComputeNodeCosts(r.Context(), client.Prom())) + resp := pkgopencost.ComputeNodeCosts(r.Context(), client.Prom()) + attachNodeProviderIDs(resp) + writeJSON(w, http.StatusOK, resp) +} + +func attachNodeProviderIDs(resp *pkgopencost.NodeCostResponse) { + if resp == nil || !resp.Available || len(resp.Nodes) == 0 { + return + } + rc := k8s.GetResourceCache() + if rc == nil || rc.Nodes() == nil { + return + } + nodes, err := rc.Nodes().List(labels.Everything()) + if err != nil || len(nodes) == 0 { + return + } + providerIDs := make(map[string]string, len(nodes)) + for _, node := range nodes { + if node.Spec.ProviderID != "" { + providerIDs[node.Name] = node.Spec.ProviderID + } + } + for i := range resp.Nodes { + resp.Nodes[i].ProviderID = providerIDs[resp.Nodes[i].Name] + } } diff --git a/pkg/opencost/types.go b/pkg/opencost/types.go index 50d1aec78..9ab3880ce 100644 --- a/pkg/opencost/types.go +++ b/pkg/opencost/types.go @@ -199,6 +199,7 @@ type NodeCostResponse struct { // NodeCost holds per-node cost breakdown. type NodeCost struct { Name string `json:"name"` + ProviderID string `json:"providerID,omitempty"` InstanceType string `json:"instanceType,omitempty"` Region string `json:"region,omitempty"` HourlyCost float64 `json:"hourlyCost"` diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 03436beef..a0198da43 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -813,6 +813,7 @@ export function useOpenCostApplicationCostTrend(workloads: OpenCostApplicationWo // Node cost breakdown export interface OpenCostNodeCost { name: string + providerID?: string instanceType?: string region?: string hourlyCost: number diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index 71f3386f2..9e9b14f4e 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -137,7 +137,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0) return ( -
+
{(current?.partial || trend?.partial || state === 'partial_missing_history' || diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx index 9f548bbbe..4cdb27bd7 100644 --- a/web/src/components/cost/CostView.tsx +++ b/web/src/components/cost/CostView.tsx @@ -1,7 +1,13 @@ import { useState, useEffect } from 'react' -import { COST_DISCOVERY_GRACE_MS, useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client' +import { + COST_DISCOVERY_GRACE_MS, + useClusterInfo, + useOpenCostSummary, + useOpenCostWorkloads, + useOpenCostNodes, +} from '../../api/client' import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client' -import { ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react' +import { ChevronDown, ChevronRight, DollarSign, ExternalLink, HelpCircle, Loader2, Server, X } from 'lucide-react' import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui' import { CostTrendChart } from './CostTrendChart' import { @@ -15,7 +21,8 @@ import { import { Tooltip } from '../ui/Tooltip' import { useConnection } from '../../context/ConnectionContext' import type { SelectedResource } from '../../types' -import { kindToPlural } from '../../utils/navigation' +import { kindToPlural, openExternal } from '../../utils/navigation' +import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console' interface CostViewProps { onBack: () => void @@ -25,6 +32,7 @@ interface CostViewProps { export function CostView({ onBack, onOpenResource }: CostViewProps) { const { data, isLoading, isFetching, dataUpdatedAt, refetch } = useOpenCostSummary() const { data: nodeData } = useOpenCostNodes() + const { data: clusterInfo } = useClusterInfo() const { connection } = useConnection() const [showHelp, setShowHelp] = useState(false) const [noPrometheusSince, setNoPrometheusSince] = useState(null) @@ -119,6 +127,7 @@ export function CostView({ onBack, onOpenResource }: CostViewProps) { const storagePct = allocTotal > 0 ? (totalStorage / allocTotal) * 100 : 0 const nodes = nodeData?.available ? (nodeData.nodes ?? []) : [] + const clusterConsoleLink = clusterCloudConsoleLink(clusterInfo?.context) return (
@@ -138,6 +147,19 @@ export function CostView({ onBack, onOpenResource }: CostViewProps) { How this works + {clusterConsoleLink && ( + + + + )}
{/* Tracks the headline monthly summary (the primary query); its load @@ -257,7 +279,7 @@ export function CostView({ onBack, onOpenResource }: CostViewProps) {
{/* Node cost table */} - {nodes.length > 0 && } + {nodes.length > 0 && } {/* Footer */}
@@ -470,7 +492,13 @@ function WorkloadCostRow({ return
{content}
} -function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) { +function NodeCostTable({ + nodes, + onOpenResource, +}: { + nodes: OpenCostNodeCost[] + onOpenResource?: (resource: SelectedResource) => void +}) { return (
@@ -506,19 +534,52 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) { {/* Node rows */}
{nodes.map((node) => ( - + ))}
) } -function NodeCostRow({ node }: { node: OpenCostNodeCost }) { +function NodeCostRow({ + node, + onOpenResource, +}: { + node: OpenCostNodeCost + onOpenResource?: (resource: SelectedResource) => void +}) { + const cloudLink = nodeCloudConsoleLink(node.providerID) + const openNode = () => onOpenResource?.({ kind: 'nodes', namespace: '', name: node.name }) + return (
- - {node.name} - + + + {onOpenResource ? ( + + ) : ( + {node.name} + )} + + {cloudLink && ( + + + + )} + {node.instanceType || '-'} {node.region && ({node.region})} diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index 655d17781..5ad2865d8 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -119,7 +119,7 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) : formatCost(0) return ( -
+
diff --git a/web/src/components/cost/cloud-console.test.ts b/web/src/components/cost/cloud-console.test.ts new file mode 100644 index 000000000..27c519673 --- /dev/null +++ b/web/src/components/cost/cloud-console.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console' + +describe('cloud console links', () => { + it('builds Google Compute Engine node links from GCE provider IDs', () => { + expect(nodeCloudConsoleLink('gce://proj-1/us-east1-b/gke-node-1')).toEqual({ + label: 'Open in Google Cloud Console', + url: 'https://console.cloud.google.com/compute/instancesDetail/zones/us-east1-b/instances/gke-node-1?project=proj-1', + }) + }) + + it('builds AWS EC2 node links from AWS provider IDs', () => { + expect(nodeCloudConsoleLink('aws:///us-east-1a/i-0123456789abcdef0')).toEqual({ + label: 'Open in AWS Console', + url: 'https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#InstanceDetails:instanceId=i-0123456789abcdef0', + }) + }) + + it('omits ambiguous node links', () => { + expect(nodeCloudConsoleLink('aws:///us-east-1-wl1-bos-wlz-1/i-0123456789abcdef0')).toBeNull() + expect(nodeCloudConsoleLink('azure:///subscriptions/123/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm')).toBeNull() + }) + + it('builds high-confidence cluster links from canonical contexts', () => { + expect(clusterCloudConsoleLink('gke_proj-1_us-east1_nonprod')).toEqual({ + label: 'Open cluster in Google Cloud Console', + url: 'https://console.cloud.google.com/kubernetes/clusters/details/us-east1/nonprod/details?project=proj-1', + }) + expect(clusterCloudConsoleLink('arn:aws:eks:us-west-2:123456789012:cluster/prod')).toEqual({ + label: 'Open cluster in AWS Console', + url: 'https://us-west-2.console.aws.amazon.com/eks/home?region=us-west-2#/clusters/prod', + }) + }) + + it('omits renamed or non-commercial-partition cluster contexts', () => { + expect(clusterCloudConsoleLink('nonprod-cluster-us-east1')).toBeNull() + expect(clusterCloudConsoleLink('arn:aws-us-gov:eks:us-gov-west-1:123456789012:cluster/prod')).toBeNull() + }) +}) diff --git a/web/src/components/cost/cloud-console.ts b/web/src/components/cost/cloud-console.ts new file mode 100644 index 000000000..828a47f12 --- /dev/null +++ b/web/src/components/cost/cloud-console.ts @@ -0,0 +1,81 @@ +export interface CloudConsoleLink { + label: string + url: string +} + +export function nodeCloudConsoleLink(providerID?: string): CloudConsoleLink | null { + if (!providerID) return null + + const gce = parseGCEProviderID(providerID) + if (gce) { + return { + label: 'Open in Google Cloud Console', + url: `https://console.cloud.google.com/compute/instancesDetail/zones/${encodeURIComponent(gce.zone)}/instances/${encodeURIComponent(gce.instance)}?project=${encodeURIComponent(gce.project)}`, + } + } + + const aws = parseAWSProviderID(providerID) + if (aws) { + return { + label: 'Open in AWS Console', + url: `https://${aws.region}.console.aws.amazon.com/ec2/home?region=${aws.region}#InstanceDetails:instanceId=${encodeURIComponent(aws.instanceID)}`, + } + } + + return null +} + +export function clusterCloudConsoleLink(context?: string): CloudConsoleLink | null { + if (!context) return null + + const gke = parseGKEContext(context) + if (gke) { + return { + label: 'Open cluster in Google Cloud Console', + url: `https://console.cloud.google.com/kubernetes/clusters/details/${encodeURIComponent(gke.location)}/${encodeURIComponent(gke.cluster)}/details?project=${encodeURIComponent(gke.project)}`, + } + } + + const eks = parseEKSContext(context) + if (eks) { + return { + label: 'Open cluster in AWS Console', + url: `https://${eks.region}.console.aws.amazon.com/eks/home?region=${eks.region}#/clusters/${encodeURIComponent(eks.cluster)}`, + } + } + + return null +} + +function parseGCEProviderID(providerID: string): { project: string; zone: string; instance: string } | null { + if (!providerID.startsWith('gce://')) return null + const parts = providerID.replace(/^gce:\/\/\/?/, '').split('/') + if (parts.length !== 3 || parts.some((part) => part === '')) return null + return { project: parts[0], zone: parts[1], instance: parts[2] } +} + +function parseAWSProviderID(providerID: string): { region: string; instanceID: string } | null { + if (!providerID.startsWith('aws://')) return null + const parts = providerID.replace(/^aws:\/\/\/?/, '').split('/') + if (parts.length !== 2 || parts.some((part) => part === '')) return null + const region = regionFromAWSZone(parts[0]) + if (!region) return null + return { region, instanceID: parts[1] } +} + +function parseGKEContext(context: string): { project: string; location: string; cluster: string } | null { + const match = /^gke_([^_]+)_([^_]+)_(.+)$/.exec(context) + if (!match) return null + return { project: match[1], location: match[2], cluster: match[3] } +} + +function parseEKSContext(context: string): { region: string; cluster: string } | null { + const match = /^arn:aws:eks:([^:]+):\d{12}:cluster\/(.+)$/.exec(context) + if (!match) return null + return { region: match[1], cluster: match[2] } +} + +function regionFromAWSZone(zone: string): string | null { + const match = /^([a-z]{2}-[a-z]+-\d)[a-z]$/.exec(zone) + return match?.[1] ?? null +} From cdcb3726899136d359b38e1afeb33d1c6761a960 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sat, 11 Jul 2026 16:51:50 +0300 Subject: [PATCH 11/12] Fix app cost partial review findings --- internal/server/opencost_application.go | 77 ++++++++++------ internal/server/opencost_workload.go | 88 +++++++++++++------ pkg/opencost/application_cost.go | 24 +++-- pkg/opencost/application_cost_test.go | 46 +++++++++- pkg/opencost/application_trend.go | 25 +++--- pkg/opencost/types.go | 2 + pkg/opencost/workload_trend.go | 4 +- web/src/api/client.ts | 30 ++++--- .../components/cost/ApplicationCostTab.tsx | 10 ++- 9 files changed, 217 insertions(+), 89 deletions(-) diff --git a/internal/server/opencost_application.go b/internal/server/opencost_application.go index 6b9c6e4c1..0110f1061 100644 --- a/internal/server/opencost_application.go +++ b/internal/server/opencost_application.go @@ -24,19 +24,19 @@ func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Reques return } - _, inputs, unsupported, ok := s.parseOpenCostApplicationRequest(w, r) + _, inputs, unavailable, unsupported, ok := s.parseOpenCostApplicationRequest(w, r) if !ok { return } client := prometheuspkg.GetClient() if client == nil { - s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unsupported, pkgopencost.ReasonNoPrometheus)) + s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, pkgopencost.ReasonNoPrometheus)) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { - log.Printf("[opencost] EnsureConnected failed (application cost): %v", err) - s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unsupported, pkgopencost.ReasonNoPrometheus)) + log.Print("[opencost] EnsureConnected failed for application cost") + s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, pkgopencost.ReasonNoPrometheus)) return } @@ -46,7 +46,7 @@ func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Reques r.Context(), client.Prom(), namespace, internalopencost.BuildPodOwnerLookup(namespace)) } - s.writeJSON(w, pkgopencost.BuildApplicationCostResponse(inputs, unsupported, namespaceCosts)) + s.writeJSON(w, pkgopencost.BuildApplicationCostResponse(inputs, unavailable, unsupported, namespaceCosts)) } func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.Request) { @@ -54,7 +54,7 @@ func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.R return } - req, inputs, unsupported, ok := s.parseOpenCostApplicationRequest(w, r) + req, inputs, unavailable, unsupported, ok := s.parseOpenCostApplicationRequest(w, r) if !ok { return } @@ -67,42 +67,46 @@ func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.R client := prometheuspkg.GetClient() if client == nil { s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ - Range: req.Range, - Workloads: refs, + Range: req.Range, + Workloads: refs, + Unavailable: unavailable, })) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { - log.Printf("[opencost] EnsureConnected failed (application trend): %v", err) + log.Print("[opencost] EnsureConnected failed for application trend") s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ - Range: req.Range, - Workloads: refs, + Range: req.Range, + Workloads: refs, + Unavailable: unavailable, })) return } s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.ApplicationTrendOptions{ - Range: req.Range, - Workloads: refs, + Range: req.Range, + Workloads: refs, + Unavailable: unavailable, })) } -func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http.Request) (openCostApplicationRequest, []pkgopencost.ApplicationWorkloadCostInput, []pkgopencost.ApplicationWorkloadRef, bool) { +func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http.Request) (openCostApplicationRequest, []pkgopencost.ApplicationWorkloadCostInput, []pkgopencost.ApplicationWorkloadStatus, []pkgopencost.ApplicationWorkloadRef, bool) { var req openCostApplicationRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024)).Decode(&req); err != nil { s.writeError(w, http.StatusBadRequest, "invalid application cost request") - return req, nil, nil, false + return req, nil, nil, nil, false } if len(req.Workloads) == 0 { s.writeError(w, http.StatusBadRequest, "at least one workload is required") - return req, nil, nil, false + return req, nil, nil, nil, false } if len(req.Workloads) > maxOpenCostApplicationWorkloads { s.writeError(w, http.StatusBadRequest, "too many workloads requested") - return req, nil, nil, false + return req, nil, nil, nil, false } inputs := make([]pkgopencost.ApplicationWorkloadCostInput, 0, len(req.Workloads)) + unavailable := make([]pkgopencost.ApplicationWorkloadStatus, 0) unsupported := make([]pkgopencost.ApplicationWorkloadRef, 0) seen := make(map[string]bool, len(req.Workloads)) for _, ref := range req.Workloads { @@ -111,7 +115,7 @@ func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http. ref.Name = strings.TrimSpace(ref.Name) if ref.Kind == "" || ref.Namespace == "" || ref.Name == "" || ref.Namespace == "_" { s.writeError(w, http.StatusBadRequest, "workload kind, namespace, and name are required") - return req, nil, nil, false + return req, nil, nil, nil, false } kind, supported := pkgopencost.CanonicalWorkloadKind(ref.Kind) @@ -128,17 +132,25 @@ func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http. unsupported = append(unsupported, ref) continue } - if status, msg, ok := s.preflightResourceGet(r, normalizeKind(ref.Kind), ref.Namespace, ref.Name, "apps"); !ok { - s.writeError(w, status, msg) - return req, nil, nil, false + if status, _, ok := s.preflightResourceGet(r, normalizeKind(ref.Kind), ref.Namespace, ref.Name, "apps"); !ok { + unavailable = append(unavailable, pkgopencost.ApplicationWorkloadStatus{ + ApplicationWorkloadRef: ref, + Reason: openCostUnavailableReasonForHTTPStatus(status), + }) + log.Printf("[opencost] Skipping application workload during cost preflight (status=%d)", status) + continue } - _, desiredReplicas, ok := s.loadOpenCostWorkloadResource(w, ref.Kind, ref.Namespace, ref.Name) - if !ok { - return req, nil, nil, false + lookup := s.lookupOpenCostWorkloadResource(ref.Kind, ref.Namespace, ref.Name) + if lookup.Status != 0 { + unavailable = append(unavailable, pkgopencost.ApplicationWorkloadStatus{ + ApplicationWorkloadRef: ref, + Reason: lookup.Reason, + }) + continue } inputs = append(inputs, pkgopencost.ApplicationWorkloadCostInput{ ApplicationWorkloadRef: ref, - DesiredReplicas: desiredReplicas, + DesiredReplicas: lookup.DesiredReplicas, }) } sort.Slice(inputs, func(i, j int) bool { @@ -147,7 +159,20 @@ func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http. sort.Slice(unsupported, func(i, j int) bool { return unsupported[i].Namespace+"/"+unsupported[i].Kind+"/"+unsupported[i].Name < unsupported[j].Namespace+"/"+unsupported[j].Kind+"/"+unsupported[j].Name }) - return req, inputs, unsupported, true + sort.Slice(unavailable, func(i, j int) bool { + return unavailable[i].Namespace+"/"+unavailable[i].Kind+"/"+unavailable[i].Name < unavailable[j].Namespace+"/"+unavailable[j].Kind+"/"+unavailable[j].Name + }) + return req, inputs, unavailable, unsupported, true +} + +func openCostUnavailableReasonForHTTPStatus(status int) string { + if status == http.StatusForbidden { + return pkgopencost.ReasonAccessDenied + } + if status == http.StatusNotFound { + return pkgopencost.ReasonNotFound + } + return pkgopencost.ReasonQueryError } func applicationInputNamespaces(inputs []pkgopencost.ApplicationWorkloadCostInput) []string { diff --git a/internal/server/opencost_workload.go b/internal/server/opencost_workload.go index 58e95320d..06f4a57ae 100644 --- a/internal/server/opencost_workload.go +++ b/internal/server/opencost_workload.go @@ -13,6 +13,14 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" ) +type openCostWorkloadLookup struct { + Resource any + DesiredReplicas int + Status int + Message string + Reason string +} + func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) { if !s.requireConnected(w) { return @@ -41,7 +49,7 @@ func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { - log.Printf("[opencost] EnsureConnected failed (workload %s %s/%s): %v", kind, namespace, name, err) + log.Print("[opencost] EnsureConnected failed for workload cost") resp.Available = false resp.Reason = pkgopencost.ReasonNoPrometheus s.writeJSON(w, resp) @@ -80,7 +88,7 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { - log.Printf("[opencost] EnsureConnected failed (workload trend %s %s/%s): %v", kind, namespace, name, err) + log.Print("[opencost] EnsureConnected failed for workload trend") resp.Available = false resp.Reason = pkgopencost.ReasonNoPrometheus s.writeJSON(w, resp) @@ -116,67 +124,95 @@ func (s *Server) parseOpenCostWorkloadRequest(w http.ResponseWriter, r *http.Req } func (s *Server) loadOpenCostWorkloadResource(w http.ResponseWriter, kind, namespace, name string) (any, int, bool) { + lookup := s.lookupOpenCostWorkloadResource(kind, namespace, name) + if lookup.Status != 0 { + s.writeError(w, lookup.Status, lookup.Message) + return nil, 0, false + } + return lookup.Resource, lookup.DesiredReplicas, true +} + +func (s *Server) lookupOpenCostWorkloadResource(kind, namespace, name string) openCostWorkloadLookup { cache := k8s.GetResourceCache() if cache == nil { - s.writeError(w, http.StatusServiceUnavailable, "Resource cache not available") - return nil, 0, false + return openCostWorkloadLookup{ + Status: http.StatusServiceUnavailable, + Message: "Resource cache not available", + Reason: pkgopencost.ReasonQueryError, + } } switch kind { case "Deployment": if cache.Deployments() == nil { - s.writeError(w, http.StatusForbidden, "insufficient permissions to access deployments") - return nil, 0, false + return openCostWorkloadLookup{ + Status: http.StatusForbidden, + Message: "insufficient permissions to access deployments", + Reason: pkgopencost.ReasonAccessDenied, + } } deploy, err := cache.Deployments().Deployments(namespace).Get(name) if err != nil { - s.writeOpenCostWorkloadGetError(w, kind, namespace, name, err) - return nil, 0, false + return openCostWorkloadGetError(kind, namespace, name, err) } replicas := int32(1) if deploy.Spec.Replicas != nil { replicas = *deploy.Spec.Replicas } - return deploy, int(replicas), true + return openCostWorkloadLookup{Resource: deploy, DesiredReplicas: int(replicas)} case "StatefulSet": if cache.StatefulSets() == nil { - s.writeError(w, http.StatusForbidden, "insufficient permissions to access statefulsets") - return nil, 0, false + return openCostWorkloadLookup{ + Status: http.StatusForbidden, + Message: "insufficient permissions to access statefulsets", + Reason: pkgopencost.ReasonAccessDenied, + } } sts, err := cache.StatefulSets().StatefulSets(namespace).Get(name) if err != nil { - s.writeOpenCostWorkloadGetError(w, kind, namespace, name, err) - return nil, 0, false + return openCostWorkloadGetError(kind, namespace, name, err) } replicas := int32(1) if sts.Spec.Replicas != nil { replicas = *sts.Spec.Replicas } - return sts, int(replicas), true + return openCostWorkloadLookup{Resource: sts, DesiredReplicas: int(replicas)} case "DaemonSet": if cache.DaemonSets() == nil { - s.writeError(w, http.StatusForbidden, "insufficient permissions to access daemonsets") - return nil, 0, false + return openCostWorkloadLookup{ + Status: http.StatusForbidden, + Message: "insufficient permissions to access daemonsets", + Reason: pkgopencost.ReasonAccessDenied, + } } ds, err := cache.DaemonSets().DaemonSets(namespace).Get(name) if err != nil { - s.writeOpenCostWorkloadGetError(w, kind, namespace, name, err) - return nil, 0, false + return openCostWorkloadGetError(kind, namespace, name, err) } - return ds, int(ds.Status.DesiredNumberScheduled), true + return openCostWorkloadLookup{Resource: ds, DesiredReplicas: int(ds.Status.DesiredNumberScheduled)} default: - s.writeError(w, http.StatusBadRequest, "only deployments, statefulsets, and daemonsets are supported") - return nil, 0, false + return openCostWorkloadLookup{ + Status: http.StatusBadRequest, + Message: "only deployments, statefulsets, and daemonsets are supported", + Reason: pkgopencost.ReasonQueryError, + } } } -func (s *Server) writeOpenCostWorkloadGetError(w http.ResponseWriter, kind, namespace, name string, err error) { +func openCostWorkloadGetError(kind, namespace, name string, err error) openCostWorkloadLookup { if apierrors.IsNotFound(err) { - s.writeError(w, http.StatusNotFound, fmt.Sprintf("%s %s/%s not found", kind, namespace, name)) - return + return openCostWorkloadLookup{ + Status: http.StatusNotFound, + Message: fmt.Sprintf("%s %s/%s not found", kind, namespace, name), + Reason: pkgopencost.ReasonNotFound, + } + } + log.Print("[opencost] Failed to get workload for cost") + return openCostWorkloadLookup{ + Status: http.StatusInternalServerError, + Message: "failed to get workload", + Reason: pkgopencost.ReasonQueryError, } - log.Printf("[opencost] Failed to get %s %s/%s: %v", kind, namespace, name, err) - s.writeError(w, http.StatusInternalServerError, "failed to get workload") } func focusOpenCostWorkload(resp *pkgopencost.WorkloadCostResponse, kind, namespace, name string, desiredReplicas int) *pkgopencost.WorkloadCostDetailResponse { diff --git a/pkg/opencost/application_cost.go b/pkg/opencost/application_cost.go index 9addd158c..8a76c9865 100644 --- a/pkg/opencost/application_cost.go +++ b/pkg/opencost/application_cost.go @@ -4,14 +4,23 @@ import "sort" // BuildApplicationCostResponse folds namespace workload-cost responses into a // single app-scoped current-cost response. -func BuildApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unsupported []ApplicationWorkloadRef, namespaceCosts map[string]*WorkloadCostResponse) *ApplicationCostResponse { +func BuildApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unavailable []ApplicationWorkloadStatus, unsupported []ApplicationWorkloadRef, namespaceCosts map[string]*WorkloadCostResponse) *ApplicationCostResponse { out := &ApplicationCostResponse{ Coverage: ApplicationCostCoverage{ - Total: len(inputs) + len(unsupported), + Total: len(inputs) + len(unavailable) + len(unsupported), + Unavailable: append([]ApplicationWorkloadStatus(nil), unavailable...), Unsupported: append([]ApplicationWorkloadRef(nil), unsupported...), }, } + for _, status := range unavailable { + out.Workloads = append(out.Workloads, ApplicationWorkloadCost{ + ApplicationWorkloadRef: status.ApplicationWorkloadRef, + Reason: status.Reason, + ScaledToZero: status.ScaledToZero, + }) + } + for _, input := range inputs { row := focusApplicationWorkloadCost(input, namespaceCosts[input.Namespace]) out.Workloads = append(out.Workloads, row) @@ -55,8 +64,9 @@ func BuildApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unsuppo // UnavailableApplicationCostResponse returns a shaped app response for // cluster-wide failures that happen before per-namespace cost can be queried. -func UnavailableApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unsupported []ApplicationWorkloadRef, reason string) *ApplicationCostResponse { - statuses := make([]ApplicationWorkloadStatus, 0, len(inputs)) +func UnavailableApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unavailable []ApplicationWorkloadStatus, unsupported []ApplicationWorkloadRef, reason string) *ApplicationCostResponse { + statuses := make([]ApplicationWorkloadStatus, 0, len(inputs)+len(unavailable)) + statuses = append(statuses, unavailable...) for _, input := range inputs { statuses = append(statuses, ApplicationWorkloadStatus{ ApplicationWorkloadRef: input.ApplicationWorkloadRef, @@ -69,9 +79,9 @@ func UnavailableApplicationCostResponse(inputs []ApplicationWorkloadCostInput, u return &ApplicationCostResponse{ Available: false, Reason: reason, - Partial: len(unsupportedCopy) > 0, + Partial: len(unsupportedCopy) > 0 || len(unavailable) > 0, Coverage: ApplicationCostCoverage{ - Total: len(inputs) + len(unsupportedCopy), + Total: len(inputs) + len(unavailable) + len(unsupportedCopy), Unavailable: statuses, Unsupported: unsupportedCopy, }, @@ -151,7 +161,7 @@ func finalizeApplicationCostTotals(total *ApplicationCostTotals) { func applicationUnavailableReason(statuses []ApplicationWorkloadStatus) string { for _, status := range statuses { - if status.Reason == ReasonQueryError || status.Reason == ReasonNoPrometheus { + if status.Reason == ReasonQueryError || status.Reason == ReasonNoPrometheus || status.Reason == ReasonAccessDenied { return status.Reason } } diff --git a/pkg/opencost/application_cost_test.go b/pkg/opencost/application_cost_test.go index e5e77afb9..d6b748198 100644 --- a/pkg/opencost/application_cost_test.go +++ b/pkg/opencost/application_cost_test.go @@ -8,8 +8,12 @@ func TestBuildApplicationCostResponse_PartialAndScaledToZero(t *testing.T) { {ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "Deployment", Name: "scaled"}, DesiredReplicas: 0}, {ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "StatefulSet", Name: "missing"}, DesiredReplicas: 1}, } + unavailable := []ApplicationWorkloadStatus{{ + ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "Deployment", Name: "private"}, + Reason: ReasonAccessDenied, + }} unsupported := []ApplicationWorkloadRef{{Namespace: "default", Kind: "Job", Name: "import"}} - got := BuildApplicationCostResponse(inputs, unsupported, map[string]*WorkloadCostResponse{ + got := BuildApplicationCostResponse(inputs, unavailable, unsupported, map[string]*WorkloadCostResponse{ "default": { Available: true, Namespace: "default", @@ -32,12 +36,19 @@ func TestBuildApplicationCostResponse_PartialAndScaledToZero(t *testing.T) { if !got.Partial { t.Fatalf("expected Partial=true, got %+v", got) } - if got.Coverage.Total != 4 || got.Coverage.Included != 2 { - t.Fatalf("coverage = %+v, want total=4 included=2", got.Coverage) + if got.Coverage.Total != 5 || got.Coverage.Included != 2 { + t.Fatalf("coverage = %+v, want total=5 included=2", got.Coverage) } - if len(got.Coverage.Unavailable) != 1 || got.Coverage.Unavailable[0].Name != "missing" || got.Coverage.Unavailable[0].Reason != ReasonNoMetrics { + if len(got.Coverage.Unavailable) != 2 { t.Fatalf("unexpected unavailable coverage: %+v", got.Coverage.Unavailable) } + reasonsByName := map[string]string{} + for _, status := range got.Coverage.Unavailable { + reasonsByName[status.Name] = status.Reason + } + if reasonsByName["missing"] != ReasonNoMetrics || reasonsByName["private"] != ReasonAccessDenied { + t.Fatalf("unexpected unavailable reasons: %+v", got.Coverage.Unavailable) + } if len(got.Coverage.Unsupported) != 1 || got.Coverage.Unsupported[0].Kind != "Job" { t.Fatalf("unexpected unsupported coverage: %+v", got.Coverage.Unsupported) } @@ -55,4 +66,31 @@ func TestBuildApplicationCostResponse_PartialAndScaledToZero(t *testing.T) { if scaled == nil || !scaled.Available || !scaled.ScaledToZero || scaled.Current == nil || scaled.Current.HourlyCost != 0 { t.Fatalf("scaled-to-zero row not preserved as valid zero: %+v", scaled) } + var private *ApplicationWorkloadCost + for i := range got.Workloads { + if got.Workloads[i].Name == "private" { + private = &got.Workloads[i] + break + } + } + if private == nil || private.Available || private.Reason != ReasonAccessDenied { + t.Fatalf("prevalidated unavailable row not preserved: %+v", private) + } +} + +func TestBuildApplicationCostResponse_AllPrevalidatedUnavailable(t *testing.T) { + got := BuildApplicationCostResponse(nil, []ApplicationWorkloadStatus{{ + ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "Deployment", Name: "private"}, + Reason: ReasonAccessDenied, + }}, nil, nil) + + if got.Available { + t.Fatalf("expected unavailable response, got %+v", got) + } + if got.Reason != ReasonAccessDenied { + t.Fatalf("Reason = %q, want %q", got.Reason, ReasonAccessDenied) + } + if got.Coverage.Total != 1 || len(got.Workloads) != 1 || got.Workloads[0].Reason != ReasonAccessDenied { + t.Fatalf("unexpected response: %+v", got) + } } diff --git a/pkg/opencost/application_trend.go b/pkg/opencost/application_trend.go index 9a92158f0..a13d87bf6 100644 --- a/pkg/opencost/application_trend.go +++ b/pkg/opencost/application_trend.go @@ -12,8 +12,9 @@ import ( ) type ApplicationTrendOptions struct { - Range string - Workloads []ApplicationWorkloadRef + Range string + Workloads []ApplicationWorkloadRef + Unavailable []ApplicationWorkloadStatus } func ComputeApplicationCostTrendFromProm(ctx context.Context, client *prom.Client, opts ApplicationTrendOptions) *ApplicationCostTrendResponse { @@ -22,28 +23,30 @@ func ComputeApplicationCostTrendFromProm(ctx context.Context, client *prom.Clien resp := &ApplicationCostTrendResponse{ Range: label, Coverage: ApplicationCostCoverage{ - Total: len(supported) + len(unsupported), + Total: len(supported) + len(opts.Unavailable) + len(unsupported), + Unavailable: append([]ApplicationWorkloadStatus(nil), opts.Unavailable...), Unsupported: unsupported, }, } if client == nil { resp.Available = false resp.Reason = ReasonNoPrometheus - resp.Coverage.Unavailable = applicationStatusesForRefs(supported, ReasonNoPrometheus) - resp.Partial = len(resp.Coverage.Unsupported) > 0 + resp.Coverage.Unavailable = append(resp.Coverage.Unavailable, applicationStatusesForRefs(supported, ReasonNoPrometheus)...) + sortApplicationStatuses(resp.Coverage.Unavailable) + resp.Partial = len(resp.Coverage.Unsupported) > 0 || len(opts.Unavailable) > 0 return resp } if len(supported) == 0 { resp.Available = false - resp.Reason = ReasonNoMetrics - resp.Partial = len(resp.Coverage.Unsupported) > 0 + resp.Reason = applicationUnavailableReason(resp.Coverage.Unavailable) + resp.Partial = len(resp.Coverage.Unsupported) > 0 || len(resp.Coverage.Unavailable) > 0 return resp } byNamespace := groupApplicationRefsByNamespace(supported) for _, namespace := range sortedNamespaceKeys(byNamespace) { refs := byNamespace[namespace] - result, reason := queryApplicationTrendNamespace(ctx, client, namespace, refs, start, end, step, label) + result, reason := queryApplicationTrendNamespace(ctx, client, namespace, refs, start, end, step) if reason != "" { resp.Coverage.Unavailable = append(resp.Coverage.Unavailable, applicationStatusesForRefs(refs, reason)...) continue @@ -101,17 +104,17 @@ func ComputeApplicationCostTrendFromProm(ctx context.Context, client *prom.Clien return resp } -func queryApplicationTrendNamespace(ctx context.Context, client *prom.Client, namespace string, refs []ApplicationWorkloadRef, start, end time.Time, step time.Duration, label string) (*prom.QueryResult, string) { +func queryApplicationTrendNamespace(ctx context.Context, client *prom.Client, namespace string, refs []ApplicationWorkloadRef, start, end time.Time, step time.Duration) (*prom.QueryResult, string) { query := buildApplicationTrendQuery(namespace, refs, false) if query == "" { return nil, ReasonNoMetrics } result, err := client.QueryRange(ctx, query, start, end, step) if err != nil { - log.Printf("[opencost] app workload trend range query failed for ns=%s (range=%s), trying opencost_container_* fallback: %v", namespace, label, err) + log.Print("[opencost] app workload trend range query failed; trying opencost_container_* fallback") result, err = client.QueryRange(ctx, buildApplicationTrendQuery(namespace, refs, true), start, end, step) if err != nil { - log.Printf("[opencost] app workload trend fallback query failed for ns=%s (range=%s): %v", namespace, label, err) + log.Print("[opencost] app workload trend fallback query failed") return nil, ReasonQueryError } } diff --git a/pkg/opencost/types.go b/pkg/opencost/types.go index 9ab3880ce..ab8bc8356 100644 --- a/pkg/opencost/types.go +++ b/pkg/opencost/types.go @@ -6,6 +6,8 @@ const ( ReasonNoPrometheus = "no_prometheus" // Prometheus/VictoriaMetrics not found in cluster ReasonNoMetrics = "no_metrics" // Prometheus found but OpenCost metrics not present ReasonQueryError = "query_error" // Prometheus found but cost queries failed + ReasonAccessDenied = "access_denied" // user cannot access the requested resource + ReasonNotFound = "not_found" // requested resource no longer exists ) // CostSummary is the response for the /api/opencost/summary endpoint. diff --git a/pkg/opencost/workload_trend.go b/pkg/opencost/workload_trend.go index cee5b4498..e5eb091be 100644 --- a/pkg/opencost/workload_trend.go +++ b/pkg/opencost/workload_trend.go @@ -40,10 +40,10 @@ func ComputeWorkloadCostTrendFromProm(ctx context.Context, client *prom.Client, query := buildWorkloadTrendQuery(opts.Namespace, kind, opts.Name, false) result, err := client.QueryRange(ctx, query, start, end, step) if err != nil { - log.Printf("[opencost] workload trend range query failed for %s %s/%s (range=%s), trying opencost_container_* fallback: %v", kind, opts.Namespace, opts.Name, label, err) + log.Print("[opencost] workload trend range query failed; trying opencost_container_* fallback") result, err = client.QueryRange(ctx, buildWorkloadTrendQuery(opts.Namespace, kind, opts.Name, true), start, end, step) if err != nil { - log.Printf("[opencost] workload trend fallback query failed for %s %s/%s (range=%s): %v", kind, opts.Namespace, opts.Name, label, err) + log.Print("[opencost] workload trend fallback query failed") resp.Available = false resp.Reason = ReasonQueryError return resp diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a0198da43..9dbc032d1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -554,7 +554,7 @@ export interface OpenCostNamespaceCost { idleCost?: number } -export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error' +export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found' export interface OpenCostSummary { available: boolean @@ -570,7 +570,7 @@ export interface OpenCostSummary { const noPrometheusFirstSeenAt = new Map() -function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTERVAL_MS) { +function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTERVAL_MS, contextName?: string) { return (query: { queryHash?: string queryKey?: unknown @@ -580,7 +580,7 @@ function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTE } }) => { const data = query.state.data - const queryID = query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost') + const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}` if (data?.available === false && data.reason === 'no_prometheus') { const now = Date.now() const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now @@ -593,10 +593,11 @@ function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTE } export function useOpenCostSummary() { + const clusterInfo = useClusterInfo() return useQuery({ queryKey: ['opencost-summary'], queryFn: () => fetchJSON('/opencost/summary'), - refetchInterval: costRefetchInterval(), + refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context), staleTime: 30000, placeholderData: (prev) => prev, // Keep previous data visible during refetch }) @@ -624,11 +625,12 @@ export interface OpenCostWorkloadResponse { } export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) { + const clusterInfo = useClusterInfo() return useQuery({ queryKey: ['opencost-workloads', namespace], queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`), enabled: (options?.enabled ?? true) && Boolean(namespace), - refetchInterval: costRefetchInterval(false), + refetchInterval: costRefetchInterval(false, clusterInfo.data?.context), staleTime: 30000, }) } @@ -643,12 +645,13 @@ export interface OpenCostWorkloadDetailResponse { } export function useOpenCostWorkload(kind: string, namespace: string, name: string, options?: { enabled?: boolean }) { + const clusterInfo = useClusterInfo() return useQuery({ queryKey: ['opencost-workload', kind, namespace, name], queryFn: () => fetchJSON(`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`), enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name), staleTime: 30000, - refetchInterval: costRefetchInterval(), + refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context), placeholderData: (prev) => prev, }) } @@ -674,11 +677,12 @@ export interface OpenCostTrendResponse { } export function useOpenCostTrend(range_: CostTimeRange = '24h') { + const clusterInfo = useClusterInfo() return useQuery({ queryKey: ['opencost-trend', range_], queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`), staleTime: 60000, - refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context), placeholderData: (prev) => prev, }) } @@ -695,12 +699,13 @@ export interface OpenCostWorkloadTrendResponse { } export function useOpenCostWorkloadTrend(kind: string, namespace: string, name: string, range_: CostTimeRange = '24h', options?: { enabled?: boolean }) { + const clusterInfo = useClusterInfo() return useQuery({ queryKey: ['opencost-workload-trend', kind, namespace, name, range_], queryFn: () => fetchJSON(`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`), enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name), staleTime: 60000, - refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context), placeholderData: (prev) => prev, }) } @@ -777,6 +782,7 @@ function stableOpenCostWorkloadRefs(workloads: OpenCostApplicationWorkloadRef[]) } export function useOpenCostApplicationCost(workloads: OpenCostApplicationWorkloadRef[], options?: { enabled?: boolean }) { + const clusterInfo = useClusterInfo() const refs = stableOpenCostWorkloadRefs(workloads) return useQuery({ queryKey: ['opencost-application', refs], @@ -788,12 +794,13 @@ export function useOpenCostApplicationCost(workloads: OpenCostApplicationWorkloa }), enabled: (options?.enabled ?? true) && refs.length > 0, staleTime: 30000, - refetchInterval: costRefetchInterval(), + refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context), placeholderData: (prev) => prev, }) } export function useOpenCostApplicationCostTrend(workloads: OpenCostApplicationWorkloadRef[], range_: CostTimeRange = '24h', options?: { enabled?: boolean }) { + const clusterInfo = useClusterInfo() const refs = stableOpenCostWorkloadRefs(workloads) return useQuery({ queryKey: ['opencost-application-trend', refs, range_], @@ -805,7 +812,7 @@ export function useOpenCostApplicationCostTrend(workloads: OpenCostApplicationWo }), enabled: (options?.enabled ?? true) && refs.length > 0, staleTime: 60000, - refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context), placeholderData: (prev) => prev, }) } @@ -828,11 +835,12 @@ export interface OpenCostNodeResponse { } export function useOpenCostNodes() { + const clusterInfo = useClusterInfo() return useQuery({ queryKey: ['opencost-nodes'], queryFn: () => fetchJSON('/opencost/nodes'), staleTime: 60000, - refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS), + refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context), placeholderData: (prev) => prev, }) } diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index 9e9b14f4e..30188203d 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -147,7 +147,7 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App Showing tracked steady-state compute for {included} of {total} workloads. {unavailableCount > 0 - ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} had no cost metrics for this window.` + ? ` ${unavailableCount} workload${unavailableCount === 1 ? '' : 's'} could not be included for this window.` : ''} {unsupportedCount > 0 ? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.` @@ -356,7 +356,7 @@ export function getApplicationCostState( if (loading) return 'loading' const reason = current?.reason ?? trend?.reason - if (reason === 'no_prometheus' || reason === 'query_error') return reason + if (reason === 'no_prometheus' || reason === 'query_error' || reason === 'access_denied' || reason === 'not_found') return reason return 'no_metrics' } @@ -443,6 +443,8 @@ function applicationCostKey(ref: { kind: string; namespace: string; name: string function reasonLabel(reason?: CostUnavailableReason) { if (reason === 'no_prometheus') return 'Prometheus not found' if (reason === 'query_error') return 'Cost query failed' + if (reason === 'access_denied') return 'No access to this workload' + if (reason === 'not_found') return 'Workload not found' return 'No workload cost metrics' } @@ -482,6 +484,10 @@ function ApplicationCostUnavailable({ ? 'Prometheus not found. OpenCost application cost requires Prometheus or VictoriaMetrics.' : state === 'query_error' ? 'Cost data is temporarily unavailable. Prometheus was found, but application cost queries failed.' + : state === 'access_denied' + ? 'Cost data is unavailable because these workloads are not accessible with your current permissions.' + : state === 'not_found' + ? 'Cost data is unavailable because the referenced workloads no longer exist.' : state === 'load_error' ? 'Could not load application cost data. Check access to these workloads and try again.' : 'OpenCost workload metrics were not found for this application.') From b5cc97f3b776964adf5a91b29d721495583b92b8 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sat, 11 Jul 2026 16:59:30 +0300 Subject: [PATCH 12/12] Handle unavailable app cost states --- web/src/components/cost/ApplicationCostTab.test.ts | 11 +++++++++++ web/src/components/cost/ApplicationCostTab.tsx | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/web/src/components/cost/ApplicationCostTab.test.ts b/web/src/components/cost/ApplicationCostTab.test.ts index 478b7de35..d06129728 100644 --- a/web/src/components/cost/ApplicationCostTab.test.ts +++ b/web/src/components/cost/ApplicationCostTab.test.ts @@ -56,4 +56,15 @@ describe('getApplicationCostState', () => { it('separates query load failures from absent app metrics', () => { expect(getApplicationCostState(undefined, undefined, { currentError: true })).toBe('load_error') }) + + it('surfaces app workload access and existence failures', () => { + const current: OpenCostApplicationCostResponse = { + available: false, + reason: 'access_denied', + totals: { hourlyCost: 0, cpuCost: 0, memoryCost: 0, replicas: 0 }, + coverage: { total: 1, included: 0, unavailable: [{ kind: 'Deployment', namespace: 'prod', name: 'api', reason: 'access_denied' }] }, + } + + expect(getApplicationCostState(current, undefined, { currentLoading: false, trendLoading: false })).toBe('access_denied') + }) }) diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index 30188203d..524a4af9d 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -101,7 +101,14 @@ export function ApplicationCostTab({ app, workloads, onSelectWorkloadCost }: App ) } - if (state === 'no_prometheus' || state === 'no_metrics' || state === 'query_error' || state === 'load_error') { + if ( + state === 'no_prometheus' || + state === 'no_metrics' || + state === 'query_error' || + state === 'access_denied' || + state === 'not_found' || + state === 'load_error' + ) { const discoveryAgeMs = noPrometheusSince == null ? 0 : Date.now() - noPrometheusSince if (state === 'no_prometheus' && discoveryAgeMs < COST_DISCOVERY_GRACE_MS) { return (