+ {renderCostTab({ kind: resource?.kind || kind, namespace, name })}
+
+ )}
{effectiveTab === 'yaml' && (
{renderRelatedYaml && yamlObjects.length > 1 && (
diff --git a/pkg/opencost/application_cost.go b/pkg/opencost/application_cost.go
new file mode 100644
index 000000000..8a76c9865
--- /dev/null
+++ b/pkg/opencost/application_cost.go
@@ -0,0 +1,188 @@
+package opencost
+
+import "sort"
+
+// BuildApplicationCostResponse folds namespace workload-cost responses into a
+// single app-scoped current-cost response.
+func BuildApplicationCostResponse(inputs []ApplicationWorkloadCostInput, unavailable []ApplicationWorkloadStatus, unsupported []ApplicationWorkloadRef, namespaceCosts map[string]*WorkloadCostResponse) *ApplicationCostResponse {
+ out := &ApplicationCostResponse{
+ Coverage: ApplicationCostCoverage{
+ 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)
+ 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, 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,
+ Reason: reason,
+ })
+ }
+ sortApplicationStatuses(statuses)
+ unsupportedCopy := append([]ApplicationWorkloadRef(nil), unsupported...)
+ sortApplicationRefs(unsupportedCopy)
+ return &ApplicationCostResponse{
+ Available: false,
+ Reason: reason,
+ Partial: len(unsupportedCopy) > 0 || len(unavailable) > 0,
+ Coverage: ApplicationCostCoverage{
+ Total: len(inputs) + len(unavailable) + 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 || status.Reason == ReasonAccessDenied {
+ 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..d6b748198
--- /dev/null
+++ b/pkg/opencost/application_cost_test.go
@@ -0,0 +1,96 @@
+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},
+ }
+ unavailable := []ApplicationWorkloadStatus{{
+ ApplicationWorkloadRef: ApplicationWorkloadRef{Namespace: "default", Kind: "Deployment", Name: "private"},
+ Reason: ReasonAccessDenied,
+ }}
+ unsupported := []ApplicationWorkloadRef{{Namespace: "default", Kind: "Job", Name: "import"}}
+ got := BuildApplicationCostResponse(inputs, unavailable, 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 != 5 || got.Coverage.Included != 2 {
+ t.Fatalf("coverage = %+v, want total=5 included=2", got.Coverage)
+ }
+ 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)
+ }
+ 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)
+ }
+ 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
new file mode 100644
index 000000000..a13d87bf6
--- /dev/null
+++ b/pkg/opencost/application_trend.go
@@ -0,0 +1,269 @@
+package opencost
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/skyhook-io/radar/pkg/prom"
+)
+
+type ApplicationTrendOptions struct {
+ Range string
+ Workloads []ApplicationWorkloadRef
+ Unavailable []ApplicationWorkloadStatus
+}
+
+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(opts.Unavailable) + len(unsupported),
+ Unavailable: append([]ApplicationWorkloadStatus(nil), opts.Unavailable...),
+ Unsupported: unsupported,
+ },
+ }
+ if client == nil {
+ resp.Available = false
+ resp.Reason = ReasonNoPrometheus
+ 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 = 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)
+ 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) (*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.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.Print("[opencost] app workload trend fallback query failed")
+ 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/compute.go b/pkg/opencost/compute.go
index aed17c234..6fee41fc9 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,21 +381,21 @@ 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)
}
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
new file mode 100644
index 000000000..4fec174f0
--- /dev/null
+++ b/pkg/opencost/queries.go
@@ -0,0 +1,10 @@
+package opencost
+
+const (
+ 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/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/types.go b/pkg/opencost/types.go
index 8c8d22b61..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.
@@ -27,7 +29,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,10 +50,20 @@ 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"`
- 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"`
@@ -70,6 +82,103 @@ 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"`
+}
+
+// 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"`
@@ -92,6 +201,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/pkg/opencost/workload_trend.go b/pkg/opencost/workload_trend.go
new file mode 100644
index 000000000..e5eb091be
--- /dev/null
+++ b/pkg/opencost/workload_trend.go
@@ -0,0 +1,162 @@
+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.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.Print("[opencost] workload trend fallback query failed")
+ 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)
+ max by (namespace, pod, replicaset) (
+ label_replace(kube_pod_owner{namespace="%s", owner_kind="ReplicaSet"}, "replicaset", "$1", "owner_name", "(.+)")
+ )
+ * on(namespace, replicaset) group_left()
+ 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)
+ max by (namespace, pod, 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() `+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() `+nodeRAMHourlyCostExpr+`
+)`, 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..8f923dd91
--- /dev/null
+++ b/pkg/opencost/workload_trend_test.go
@@ -0,0 +1,107 @@
+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, `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)
+ }
+ 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, `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)
+ }
+}
+
+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/pkg/opencost/workloads.go b/pkg/opencost/workloads.go
index e94cee8fe..8072333aa 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)
}
@@ -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/api/client.ts b/web/src/api/client.ts
index 2c491caca..9dbc032d1 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
@@ -144,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)
@@ -550,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
@@ -564,11 +568,36 @@ export interface OpenCostSummary {
namespaces?: OpenCostNamespaceCost[]
}
+const noPrometheusFirstSeenAt = new Map()
+
+function costRefetchInterval(defaultInterval: number | false = COST_REFRESH_INTERVAL_MS, contextName?: string) {
+ return (query: {
+ queryHash?: string
+ queryKey?: unknown
+ state: {
+ data?: { available?: boolean; reason?: CostUnavailableReason }
+ dataUpdatedAt?: number
+ }
+ }) => {
+ const data = query.state.data
+ 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
+ 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() {
+ const clusterInfo = useClusterInfo()
return useQuery({
queryKey: ['opencost-summary'],
queryFn: () => fetchJSON('/opencost/summary'),
- refetchInterval: COST_REFRESH_INTERVAL_MS,
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
staleTime: 30000,
placeholderData: (prev) => prev, // Keep previous data visible during refetch
})
@@ -596,14 +625,37 @@ 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, clusterInfo.data?.context),
staleTime: 30000,
})
}
+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 }) {
+ 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(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
+ placeholderData: (prev) => prev,
+ })
+}
+
// Cost trend over time
export type CostTimeRange = '6h' | '24h' | '7d'
@@ -625,11 +677,142 @@ 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: 120000, // Refresh every 2 minutes
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
+ placeholderData: (prev) => prev,
+ })
+}
+
+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 }) {
+ 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, clusterInfo.data?.context),
+ placeholderData: (prev) => prev,
+ })
+}
+
+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 clusterInfo = useClusterInfo()
+ 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(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_],
+ 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, clusterInfo.data?.context),
placeholderData: (prev) => prev,
})
}
@@ -637,6 +820,7 @@ export function useOpenCostTrend(range_: CostTimeRange = '24h') {
// Node cost breakdown
export interface OpenCostNodeCost {
name: string
+ providerID?: string
instanceType?: string
region?: string
hourlyCost: number
@@ -651,11 +835,12 @@ export interface OpenCostNodeResponse {
}
export function useOpenCostNodes() {
+ const clusterInfo = useClusterInfo()
return useQuery({
queryKey: ['opencost-nodes'],
queryFn: () => fetchJSON('/opencost/nodes'),
staleTime: 60000,
- refetchInterval: 120000,
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
placeholderData: (prev) => prev,
})
}
diff --git a/web/src/components/applications/ApplicationsView.tsx b/web/src/components/applications/ApplicationsView.tsx
index cd2593ee4..3e3b375cf 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
@@ -158,15 +165,25 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
const params = new URLSearchParams(searchParams)
params.delete('view')
params.set('workload', singleWorkloadKey)
+ if (selectedRouteView === 'cost') params.set('tab', 'cost')
setSearchParams(params, { replace: true })
- }, [searchParams, selectedWorkloadParam, setSearchParams, singleWorkloadKey, viewParam])
+ }, [searchParams, selectedRouteView, selectedWorkloadParam, setSearchParams, singleWorkloadKey, viewParam])
+ useEffect(() => {
+ if (singleWorkloadKey || selectedRouteView !== 'cost' || applicationCostAvailable) return
+ const params = new URLSearchParams(searchParams)
+ params.delete('view')
+ setSearchParams(params, { replace: true })
+ }, [applicationCostAvailable, searchParams, selectedRouteView, setSearchParams, singleWorkloadKey])
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 +196,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 +340,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')
+ })
+
+ 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
new file mode 100644
index 000000000..524a4af9d
--- /dev/null
+++ b/web/src/components/cost/ApplicationCostTab.tsx
@@ -0,0 +1,562 @@
+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 { ChartLegend, StackedAreaChart } from './CostTrendChart'
+import {
+ formatCost,
+ formatCostPerHour,
+ formatProjectedDailyRate,
+ formatProjectedMonthlyCost,
+ formatProjectedMonthlyRate,
+} 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 === '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 (
+ {
+ 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 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'} could not be included for this window.`
+ : ''}
+ {unsupportedCount > 0
+ ? ` ${unsupportedCount} batch or unsupported workload${unsupportedCount === 1 ? ' is' : 's are'} excluded from this compute view.`
+ : ''}
+
+
+ )}
+
+
+
+
+
+
+
+
Application compute cost
+
+
+
+ OpenCost CPU and memory allocation for Deployment, StatefulSet, and DaemonSet workloads
+
+
+
+
+ {TIME_RANGES.map((tr) => (
+ setRange(tr.value)}
+ className={clsx(
+ 'rounded-md px-2 py-1 text-xs transition-colors',
+ range === tr.value
+ ? 'bg-accent-muted font-medium text-accent-text'
+ : 'text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary',
+ )}
+ >
+ {tr.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {trendLoading ? (
+
+
+ Loading historical cost…
+
+ ) : hasTrend && chartSeries.length > 0 ? (
+
+
+
+
+ ) : (
+
+ No historical workload owner cost points for this range.
+
+ )}
+
+
+
+
+
+ 0 ? `${unsupportedCount} unsupported` : 'All supported workloads included'}
+ />
+
+
+
+
+
+
+
+
+
Last 1h OpenCost allocation window
+
+
+ {totals ? formatCostPerHour(currentSplit) : '—'}
+
+
+
+
+ {totals && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
Workload contributors
+
+ Projected monthly from current 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. 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.
+
+
+ )
+}
+
+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' || reason === 'access_denied' || reason === 'not_found') 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 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 ? formatProjectedMonthlyRate(hourly) : '—'}
+
+
+ {current ? formatCostPerHour(hourly) : '—'}
+
+
+
+ {current ? `${formatCost(current.cpuCost)} / ${formatCost(current.memoryCost)}` : '—'}
+
+ >
+ )
+
+ if (!onOpen) {
+ return (
+
+ {content}
+
+ )
+ }
+ return (
+
+ {content}
+
+ )
+}
+
+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'
+ if (reason === 'access_denied') return 'No access to this workload'
+ if (reason === 'not_found') return 'Workload not found'
+ 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.
+
+
+
+ {isFetching ? 'Checking…' : 'Check again'}
+
+
+
+ )
+}
+
+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 === '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.')
+ return (
+
+ )
+}
+
+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 664f6fb06..167aab9fc 100644
--- a/web/src/components/cost/CostTrendChart.tsx
+++ b/web/src/components/cost/CostTrendChart.tsx
@@ -1,11 +1,8 @@
import { useState, useMemo, useRef, useCallback } from 'react'
import { clsx } from 'clsx'
import { Loader2, TrendingUp } from 'lucide-react'
-import {
- useOpenCostTrend,
- type CostTimeRange,
- type OpenCostTrendSeries,
-} from '../../api/client'
+import { useOpenCostTrend, type CostTimeRange, type OpenCostTrendSeries } from '../../api/client'
+import { formatCostAxis, formatCostPerHour } from './format'
const SERIES_COLORS = [
'#3b82f6', // blue-500
@@ -49,18 +46,21 @@ export function CostTrendChart() {
-
Cost Trend
+
+
Cost rate trend
+
Historical OpenCost hourly allocation
+
- {TIME_RANGES.map(tr => (
+ {TIME_RANGES.map((tr) => (
setTimeRange(tr.value)}
className={clsx(
'px-2 py-1 text-xs rounded-md transition-colors',
timeRange === tr.value
- ? 'bg-skyhook-600/20 text-blue-400 font-medium'
- : 'text-theme-text-quaternary hover:text-theme-text-tertiary'
+ ? 'bg-accent-muted text-accent-text font-medium'
+ : 'text-theme-text-quaternary hover:text-theme-text-tertiary',
)}
>
{tr.label}
@@ -76,7 +76,7 @@ export function CostTrendChart() {
)
}
-function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
+export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
const svgRef = useRef(null)
const [hoverX, setHoverX] = useState(null)
@@ -106,7 +106,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
const minTs = timestamps[0]
const maxTs = timestamps[timestamps.length - 1]
- const seriesLookups = series.map(s => {
+ const seriesLookups = series.map((s) => {
const map = new Map()
for (const dp of s.dataPoints) {
map.set(dp.timestamp, dp.value)
@@ -150,20 +150,46 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
// Build stacked area paths
const paths = series.map((_, si) => {
- const topPoints = timestamps.map((ts, ti) => ({ x: toX(ts), y: toY(stacked[si][ti]) }))
- const bottomPoints = si > 0
- ? timestamps.map((ts, ti) => ({ x: toX(ts), y: toY(stacked[si - 1][ti]) }))
- : timestamps.map(ts => ({ x: toX(ts), y: toY(0) }))
+ const topPoints = timestamps.map((ts, ti) => ({
+ x: toX(ts),
+ y: toY(stacked[si][ti]),
+ }))
+ const bottomPoints =
+ si > 0
+ ? timestamps.map((ts, ti) => ({
+ x: toX(ts),
+ y: toY(stacked[si - 1][ti]),
+ }))
+ : timestamps.map((ts) => ({ x: toX(ts), y: toY(0) }))
const topPath = topPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
- const bottomPath = [...bottomPoints].reverse().map((p, i) => `${i === 0 ? 'L' : 'L'}${p.x},${p.y}`).join(' ')
+ const bottomPath = [...bottomPoints]
+ .reverse()
+ .map((p, i) => `${i === 0 ? 'L' : 'L'}${p.x},${p.y}`)
+ .join(' ')
const areaPath = topPath + ' ' + bottomPath + ' Z'
const linePath = topPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
- return { areaPath, linePath, color: SERIES_COLORS[si % SERIES_COLORS.length] }
+ return {
+ areaPath,
+ linePath,
+ color: SERIES_COLORS[si % SERIES_COLORS.length],
+ }
})
- return { timestamps, stacked, minTs, maxTs, yMax, seriesLookups, toX, toY, yTicks, xTicks, paths }
+ return {
+ timestamps,
+ stacked,
+ minTs,
+ maxTs,
+ yMax,
+ seriesLookups,
+ toX,
+ toY,
+ yTicks,
+ xTicks,
+ paths,
+ }
}, [series, plotHeight, plotWidth])
// Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
@@ -189,7 +215,11 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
const points = series.map((s, si) => {
const val = seriesLookups[si].get(closestTs) ?? 0
total += val
- return { namespace: s.namespace, value: val, color: SERIES_COLORS[si % SERIES_COLORS.length] }
+ return {
+ namespace: s.namespace,
+ value: val,
+ color: SERIES_COLORS[si % SERIES_COLORS.length],
+ }
})
return { ts: closestTs, x: toX(closestTs), total, points }
@@ -210,18 +240,15 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
return (
-
+
{/* Grid lines */}
{yTicks.map((tick, i) => (
(
-
+
))}
{/* Lines (top edges of each area) */}
@@ -283,8 +306,10 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
{/* Hover crosshair */}
{hoverData && (
{new Date(hoverData.ts * 1000).toLocaleString([], {
- month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
})}
{hoverData.points
- .filter(p => p.value > 0)
+ .filter((p) => p.value > 0)
.sort((a, b) => b.value - a.value)
.map((p, i) => (
-
+
{p.namespace}
{formatCostTooltip(p.value)}
@@ -344,7 +371,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
)
}
-function ChartLegend({ series }: { series: OpenCostTrendSeries[] }) {
+export function ChartLegend({ series }: { series: OpenCostTrendSeries[] }) {
return (
{series.map((s, i) => (
@@ -360,20 +387,8 @@ 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) return `$${value.toFixed(4)}/hr`
- return '$0.00/hr'
+ return formatCostPerHour(value)
}
function formatTimestamp(unix: number): string {
diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx
index 0e51116b8..4cdb27bd7 100644
--- a/web/src/components/cost/CostView.tsx
+++ b/web/src/components/cost/CostView.tsx
@@ -1,21 +1,49 @@
import { useState, useEffect } from 'react'
-import { 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 { ArrowLeft, 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 {
+ COST_HOURS_PER_MONTH,
+ formatCost,
+ formatCostPerHour,
+ formatProjectedDailyRate,
+ formatProjectedMonthlyCost,
+ formatProjectedMonthlyRate,
+} from './format'
import { Tooltip } from '../ui/Tooltip'
import { useConnection } from '../../context/ConnectionContext'
+import type { SelectedResource } from '../../types'
+import { kindToPlural, openExternal } from '../../utils/navigation'
+import { clusterCloudConsoleLink, nodeCloudConsoleLink } from './cloud-console'
interface CostViewProps {
onBack: () => void
+ onOpenResource?: (resource: SelectedResource) => void
}
-export function CostView({ onBack }: CostViewProps) {
- const { data, isLoading, dataUpdatedAt, refetch } = useOpenCostSummary()
+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)
+
+ 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,32 +51,68 @@ 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.
+
+
+
{
+ setNoPrometheusSince(Date.now())
+ refetch()
+ }}
+ disabled={isFetching}
+ className="text-xs text-accent-text hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled transition-colors"
+ >
+ {isFetching ? 'Checking…' : 'Check again'}
+
+
+
+ )
+ }
+ 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 (
{message}
-
+
Back to Dashboard
+ {reason === 'no_prometheus' && (
+ {
+ setNoPrometheusSince(Date.now())
+ refetch()
+ }}
+ disabled={isFetching}
+ className="text-xs text-accent-text hover:text-theme-text-primary disabled:cursor-not-allowed disabled:text-theme-text-disabled transition-colors"
+ >
+ {isFetching ? 'Checking…' : 'Check again'}
+
+ )}
)
}
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)
@@ -62,22 +126,15 @@ 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 ?? []) : []
+ const clusterConsoleLink = clusterCloudConsoleLink(clusterInfo?.context)
return (
-
+
{/* Header */}
-
-
- Dashboard
-
-
Cost Insights
@@ -90,9 +147,22 @@ export function CostView({ onBack }: CostViewProps) {
How this works
+ {clusterConsoleLink && (
+
+ openExternal(clusterConsoleLink.url)}
+ className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-accent-text transition-colors duration-150"
+ aria-label={clusterConsoleLink.label}
+ >
+
+ Cloud console
+
+
+ )}
- {/* 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
@@ -137,34 +206,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 && (
-
+
)}
@@ -178,21 +238,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
@@ -201,18 +267,25 @@ export function CostView({ onBack }: CostViewProps) {
{/* Namespace rows */}
{namespaces.map((ns) => (
-
+
))}
{/* Node cost table */}
- {nodes.length > 0 &&
}
+ {nodes.length > 0 &&
}
{/* 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
@@ -224,9 +297,18 @@ export function CostView({ onBack }: CostViewProps) {
)
}
-function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCost; maxCost: number; hasStorage: boolean }) {
+function NamespaceCostRow({
+ ns,
+ maxCost,
+ hasStorage,
+ onOpenResource,
+}: {
+ ns: OpenCostNamespaceCost
+ maxCost: number
+ hasStorage: boolean
+ onOpenResource?: (resource: SelectedResource) => void
+}) {
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
@@ -238,7 +320,7 @@ function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCo
setExpanded(!expanded)}
- className="w-full grid grid-cols-[minmax(180px,1fr)_90px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2.5 text-left hover:bg-theme-hover/50 transition-colors group"
+ className="w-full grid grid-cols-[minmax(180px,1fr)_100px_90px_80px_minmax(160px,1fr)_120px] gap-2 px-4 py-2.5 text-left hover:bg-theme-hover/50 transition-colors group"
>
{expanded ? (
@@ -246,24 +328,30 @@ function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCo
) : (
)}
- {ns.name}
+
+ {ns.name}
+
+
+
+ {formatProjectedMonthlyCost(ns.hourlyCost)}
+
+
+ {formatCostPerHour(ns.hourlyCost)}
- {formatCost(ns.hourlyCost)}
- ~{formatCost(monthlyCost)}
{hasEff ? (
<>
-
- {eff.toFixed(0)}%
-
-
+ {eff.toFixed(0)}%
>
) : (
-
)}
-
+
{hasStorage && (ns.storageCost ?? 0) > 0 && (
@@ -278,14 +366,18 @@ function NamespaceCostRow({ ns, maxCost, hasStorage }: { ns: OpenCostNamespaceCo
{/* 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) {
@@ -309,37 +401,57 @@ function WorkloadRows({ namespace }: { namespace: string }) {
return (
{workloads.map((wl) => (
-
+
))}
)
}
-function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: number }) {
- const monthlyCost = wl.hourlyCost * 730
+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.replicas > 1 && (
- {wl.replicas}x
- )}
+
+ {kindLabel}
+
+
+ {wl.name}
+
+ {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 }
>
) : (
@@ -347,7 +459,10 @@ function WorkloadCostRow({ wl, maxCost }: { wl: OpenCostWorkloadCost; maxCost: n
)}
-
+
@@ -355,11 +470,35 @@ 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 (
+ onOpenResource?.(resource)}
+ className={`${rowClass} w-full transition-colors hover:bg-theme-hover/60 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-inset`}
+ aria-label={`Open ${kindLabel} ${wl.name}`}
+ >
+ {content}
+
+ )
+ }
+
+ return {content}
}
-function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) {
+function NodeCostTable({
+ nodes,
+ onOpenResource,
+}: {
+ nodes: OpenCostNodeCost[]
+ onOpenResource?: (resource: SelectedResource) => void
+}) {
return (
@@ -379,42 +518,78 @@ function NodeCostTable({ nodes }: { nodes: OpenCostNodeCost[] }) {
{/* Table header */}
-
+
Node
Instance Type
- Hourly
-
- Monthly*
+
+ Projected/mo*
+ Hourly
CPU / Memory
{/* Node rows */}
{nodes.map((node) => (
-
+
))}
)
}
-function NodeCostRow({ node }: { node: OpenCostNodeCost }) {
- const monthlyCost = node.hourlyCost * 730
+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}
+
+ ) : (
+ {node.name}
+ )}
+
+ {cloudLink && (
+
+ openExternal(cloudLink.url)}
+ className="shrink-0 rounded p-0.5 text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-theme-base"
+ aria-label={`${cloudLink.label}: ${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)}
@@ -422,6 +597,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 }) {
@@ -457,22 +663,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.
@@ -480,26 +689,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
+
@@ -509,12 +724,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.
@@ -522,9 +737,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.
@@ -540,19 +755,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
new file mode 100644
index 000000000..6e34ee606
--- /dev/null
+++ b/web/src/components/cost/WorkloadCostTab.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest'
+import { getWorkloadCostState } from './WorkloadCostTab'
+import { buildLineChart } from './chart'
+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('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,
+ 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..5ad2865d8
--- /dev/null
+++ b/web/src/components/cost/WorkloadCostTab.tsx
@@ -0,0 +1,418 @@
+import { useEffect, useMemo, useState } from 'react'
+import { clsx } from 'clsx'
+import { AlertCircle, DollarSign, HelpCircle, 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 { Tooltip } from '../ui/Tooltip'
+import { buildLineChart, formatChartTime } from './chart'
+import {
+ formatCost,
+ formatCostAxis,
+ formatCostPerHour,
+ formatProjectedDailyRate,
+ formatProjectedMonthlyCost,
+ formatProjectedMonthlyRate,
+} from './format'
+
+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 [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, 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])
+
+ if (state === 'loading') {
+ return (
+
+
+ Loading workload 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?.current
+ 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 hourly = current?.hourlyCost ?? 0
+ 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) => (
+ setRange(tr.value)}
+ className={clsx(
+ 'rounded-md px-2 py-1 text-xs transition-colors',
+ range === tr.value
+ ? 'bg-accent-muted text-accent-text font-medium'
+ : 'text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary',
+ )}
+ >
+ {tr.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {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.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
Last 1h OpenCost allocation window
+
+
+ {hasCurrent ? formatCostPerHour(splitTotal) : '—'}
+
+
+
+
+ {hasCurrent && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+ )
+}
+
+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.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'
+ 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 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.
+
+
+
+ {isFetching ? 'Checking…' : 'Check again'}
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function MetricBlock({ label, value, subvalue }: { label: string; value: string; subvalue?: string }) {
+ return (
+
+
{label}
+
{value}
+ {subvalue &&
{subvalue}
}
+
+ )
+}
+
+function MetricTile({
+ label,
+ value,
+ subvalue,
+ tooltip,
+}: {
+ label: string
+ value: string
+ subvalue?: string
+ tooltip?: string
+}) {
+ return (
+
+
+
{label}
+ {tooltip &&
}
+
+
{value}
+ {subvalue &&
{subvalue}
}
+
+ )
+}
+
+function MetricInfoTooltip({ content }: { content: string }) {
+ return (
+
+
+
+ )
+}
+
+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) => (
+
+
+
+ {formatCostAxis(tick.value)}
+
+
+ ))}
+
+
+
+ {formatChartTime(points[0]?.timestamp)}
+
+
+ {formatChartTime(points[points.length - 1]?.timestamp)}
+
+
+
+ )
+}
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/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
+}
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
new file mode 100644
index 000000000..3330d6aec
--- /dev/null
+++ b/web/src/components/cost/format.ts
@@ -0,0 +1,41 @@
+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`
+ 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'
+}
+
+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`
+}
+
+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/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/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'
-}
diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx
index 6613a47c5..3ed667dd8 100644
--- a/web/src/components/workload/WorkloadView.tsx
+++ b/web/src/components/workload/WorkloadView.tsx
@@ -42,6 +42,8 @@ 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 { isOpenCostWorkloadKind } from '../cost/kinds'
import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
@@ -679,9 +681,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}