From b542b26dd59339739a3ef881f798a1e0dbaca914 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Mon, 24 Aug 2026 00:04:52 +0300 Subject: [PATCH 01/18] fix: support configured OpenCost currency --- README.md | 5 +- cmd/desktop/main.go | 7 ++ cmd/explorer/main.go | 6 + deploy/helm/radar/README.md | 1 + deploy/helm/radar/templates/deployment.yaml | 3 + deploy/helm/radar/values.schema.json | 11 ++ deploy/helm/radar/values.yaml | 6 + docs/configuration.md | 2 + docs/integrations.md | 6 +- go.mod | 2 +- internal/app/bootstrap.go | 4 + internal/config/config.go | 1 + internal/config/opencost.go | 25 +++++ internal/config/opencost_test.go | 31 ++++++ internal/opencost/handlers.go | 53 +++++---- internal/opencost/handlers_test.go | 95 ++++++++++++++++ internal/server/diagnostics.go | 1 + internal/server/opencost_application.go | 30 +++-- internal/server/opencost_workload.go | 12 +- internal/server/opencost_workload_test.go | 69 ++++++++++++ internal/server/server.go | 15 ++- internal/server/settings_role_test.go | 35 ++++++ .../applications/ApplicationDetail.tsx | 4 +- .../src/components/workload/WorkloadView.tsx | 4 +- pkg/opencost/compute.go | 16 +-- pkg/opencost/compute_test.go | 13 ++- pkg/opencost/trend.go | 4 +- pkg/opencost/types.go | 8 ++ scripts/test-chart.sh | 5 + web/src/api/client.ts | 7 ++ .../components/cost/ApplicationCostTab.tsx | 44 +++++--- web/src/components/cost/CostTrendChart.tsx | 30 +++-- web/src/components/cost/CostView.tsx | 105 +++++++++++++----- .../components/cost/CurrentAllocationUse.tsx | 10 +- web/src/components/cost/WorkloadCostTab.tsx | 36 ++++-- web/src/components/cost/format.test.ts | 31 ++++-- web/src/components/cost/format.ts | 91 ++++++++++----- web/src/components/home/CostCard.tsx | 19 ++-- web/src/components/nav/PrimaryNavRail.tsx | 4 +- .../rightsizing/RightsizingScanView.tsx | 4 +- .../components/settings/SettingsDialog.tsx | 1 + web/src/components/ui/command-items.ts | 4 +- 42 files changed, 685 insertions(+), 175 deletions(-) create mode 100644 internal/config/opencost.go create mode 100644 internal/config/opencost_test.go create mode 100644 internal/opencost/handlers_test.go diff --git a/README.md b/README.md index 9bafc8a02..5650c193d 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ output) for plain text. URLs, tokens, and suggested commands remain unstyled. | `--prometheus-url` | (auto-discover) | Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) | | `--prometheus-header` | | HTTP header sent with every Prometheus request, format `Key=Value` (repeatable). Required for auth-protected backends. | | `--prometheus-header-from-env` | | HTTP header sent with every Prometheus request, sourced from an environment variable, format `Key=ENV_VAR` (repeatable). | +| `--opencost-currency` | `USD` | ISO 4217 code describing OpenCost values. Radar labels values with this currency but does not convert them. | | `--auth-mode` | `none` | Authentication mode: `none`, `proxy`, or `oidc` ([details](docs/authentication.md)) | | `--no-mcp` | `false` | Disable MCP server for AI tool integration | | `--mcp-catalog-stdio` | `false` | Start only the MCP catalog over stdio for registry introspection | @@ -428,7 +429,9 @@ See [docs/capacity.md](docs/capacity.md) for the full reference. ### Cost Insights -Track Kubernetes spending with OpenCost integration — no additional configuration needed. +Track Kubernetes spending with OpenCost integration. Radar defaults to USD; if OpenCost is +configured with non-USD pricing, pass `--opencost-currency` so Radar labels the values correctly. +Radar does not convert values between currencies. - Cluster hourly and projected monthly cost, top namespaces by spend - Cost trend charts with 6h/24h/7d range selector diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 6fbc408df..5210d09db 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -66,6 +66,7 @@ func main() { timelineRetention := flag.Duration("timeline-retention", fileCfg.TimelineRetentionOr(7*24*time.Hour), "How long to retain timeline events when --timeline-storage=sqlite (e.g. 168h, 720h). 0 disables age-based cleanup.") timelineMaxSize := flag.String("timeline-max-size", fileCfg.TimelineMaxSizeOr("1Gi"), "Maximum SQLite timeline storage size before pruning oldest events (e.g. 800Mi, 8Gi). 0 disables size-based pruning.") prometheusURL := flag.String("prometheus-url", fileCfg.PrometheusURL, "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)") + openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "ISO 4217 currency code used by OpenCost values (default: USD)") flag.Parse() if *showVersion { @@ -120,6 +121,11 @@ func main() { log.Printf("ERROR: invalid --timeline-max-size %q: %v", *timelineMaxSize, err) os.Exit(1) } + normalizedOpenCostCurrency, err := config.NormalizeOpenCostCurrency(*openCostCurrency) + if err != nil { + log.Printf("ERROR: invalid --opencost-currency %q: %v", *openCostCurrency, err) + os.Exit(1) + } resolvedPrometheusHeaders, err := app.ResolvePrometheusHeaders(fileCfg.PrometheusHeaders, fileCfg.PrometheusHeadersFromEnv) if err != nil { log.Printf("ERROR: invalid Prometheus header configuration: %v", err) @@ -163,6 +169,7 @@ func main() { TimelineRetention: *timelineRetention, TimelineMaxSizeBytes: timelineMaxSizeBytes, PrometheusURL: *prometheusURL, + OpenCostCurrency: normalizedOpenCostCurrency, PrometheusHeaders: resolvedPrometheusHeaders, PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv, Version: version, diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index 7cd835729..47d2d2f6a 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -132,6 +132,7 @@ func main() { aiHistory := flag.Bool("ai-history", fileCfg.AIHistoryOr(true), "Persist AI investigations (transcripts + verdicts) to ~/.radar/ai-runs.db so they survive restarts") // Traffic/metrics options prometheusURL := flag.String("prometheus-url", fileCfg.PrometheusURL, "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)") + openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "ISO 4217 currency code used by OpenCost values (default: USD)") // --prometheus-header Key=Value, repeatable. Defaults populated from // config file; any --prometheus-header flag replaces the file value rather // than merging — matches kubectl semantics (file is the default, CLI wins). @@ -270,6 +271,10 @@ func main() { if err != nil { log.Fatalf("Invalid --timeline-max-size %q: %v", *timelineMaxSize, err) } + normalizedOpenCostCurrency, err := config.NormalizeOpenCostCurrency(*openCostCurrency) + if err != nil { + log.Fatalf("Invalid --opencost-currency %q: %v", *openCostCurrency, err) + } noMCPFlagSet := false namespaceFlagSet := false namespacesFlagSet := false @@ -349,6 +354,7 @@ func main() { TimelineRetention: *timelineRetention, TimelineMaxSizeBytes: timelineMaxSizeBytes, PrometheusURL: *prometheusURL, + OpenCostCurrency: normalizedOpenCostCurrency, PrometheusHeaders: resolvedPrometheusHeaders, PrometheusHeadersFromEnv: promHeadersFromEnv.value(), BeylaJobSelector: *beylaJobSelector, diff --git a/deploy/helm/radar/README.md b/deploy/helm/radar/README.md index e0d7a001c..f7bc7ed0c 100644 --- a/deploy/helm/radar/README.md +++ b/deploy/helm/radar/README.md @@ -172,6 +172,7 @@ lands in the Helm release state. Rotation requires a pod restart. See | `timeline.retention` | SQLite retention (Go duration; `0` disables) | `168h` | | `timeline.maxSize` | SQLite max DB + WAL size before oldest events are pruned (`0` disables) | `800Mi` | | `persistence.enabled` | Enable PVC for SQLite | `false` | +| `cost.currency` | ISO 4217 code describing OpenCost values; Radar labels but does not convert them | `""` (USD) | | `traffic.prometheusUrl` | Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) | `""` | | `traffic.prometheusHeaders` | HTTP headers sent with every Prometheus request (auth-protected backends) | `{}` | | `traffic.prometheusHeadersFromEnv` | Prometheus headers sourced from environment variables, for secret-backed auth headers | `{}` | diff --git a/deploy/helm/radar/templates/deployment.yaml b/deploy/helm/radar/templates/deployment.yaml index 8609a1e1f..92eb7c520 100644 --- a/deploy/helm/radar/templates/deployment.yaml +++ b/deploy/helm/radar/templates/deployment.yaml @@ -91,6 +91,9 @@ spec: {{- if .Values.traffic.prometheusUrl }} - --prometheus-url={{ .Values.traffic.prometheusUrl }} {{- end }} + {{- if .Values.cost.currency }} + - --opencost-currency={{ .Values.cost.currency }} + {{- end }} {{- range $k, $v := .Values.traffic.prometheusHeaders }} - {{ printf "--prometheus-header=%s=%s" $k $v | quote }} {{- end }} diff --git a/deploy/helm/radar/values.schema.json b/deploy/helm/radar/values.schema.json index cc33b04c6..325fad0e1 100644 --- a/deploy/helm/radar/values.schema.json +++ b/deploy/helm/radar/values.schema.json @@ -301,6 +301,17 @@ "enabled": { "type": "boolean" } } }, + "cost": { + "type": "object", + "additionalProperties": false, + "properties": { + "currency": { + "type": "string", + "pattern": "^$|^[A-Za-z]{3}$", + "description": "ISO 4217 code describing OpenCost values; Radar labels but does not convert them." + } + } + }, "traffic": { "type": "object", "additionalProperties": true, diff --git a/deploy/helm/radar/values.yaml b/deploy/helm/radar/values.yaml index edb679016..41d414245 100644 --- a/deploy/helm/radar/values.yaml +++ b/deploy/helm/radar/values.yaml @@ -448,6 +448,12 @@ mcp: # Set to false to disable the MCP server (useful when deployed behind authentication) enabled: true +# OpenCost display configuration +cost: + # ISO 4217 code describing the currency of OpenCost values. Radar labels + # values with this code but does not convert them. Empty defaults to USD. + currency: "" + # Traffic source configuration traffic: # Manual Prometheus/VictoriaMetrics URL (bypasses auto-discovery) diff --git a/docs/configuration.md b/docs/configuration.md index f3f9fb969..cdc7bbf46 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,6 +46,7 @@ Persistent defaults for CLI flags. CLI flags always override these values. Manag "timelineMaxSize": "0", "historyLimit": 10000, "prometheusUrl": "", + "opencostCurrency": "USD", "prometheusHeaders": {}, "mcp": true, "debugImage": "" @@ -68,6 +69,7 @@ All fields are optional — omitted fields use built-in defaults. | `timelineMaxSize` | Max SQLite DB + WAL size before pruning oldest events (`0` disables) | | `historyLimit` | Max timeline events to retain | | `prometheusUrl` | Manual Prometheus/VictoriaMetrics URL — skips auto-discovery. Useful when Prometheus is not in the same cluster or uses a non-standard service name. | +| `opencostCurrency` | ISO 4217 code describing the values produced by OpenCost (default `USD`). Radar labels values with this code but does not convert them. Equivalent CLI: `--opencost-currency`. | | `prometheusHeaders` | HTTP headers sent with every Prometheus request. Required for auth-protected backends — e.g. `{"X-Scope-OrgID": "my-org"}`. Equivalent CLI: `--prometheus-header Key=Value` (repeatable). Stored in plain text in `config.json` — protect the file accordingly. | | `argoCdUrl` | Manual argocd-server URL for the Argo CD API integration — skips auto-discovery. | | `argoCdToken` | Argo CD API token (get-only account recommended). Stored in plain text — the file is written `0600`; the token is redacted from `GET /api/config`. | diff --git a/docs/integrations.md b/docs/integrations.md index 90eb27951..d9f82277e 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1166,7 +1166,9 @@ PolicyReport findings are policy posture, not live operational failure, so they [OpenCost](https://www.opencost.io/) is a CNCF tool for Kubernetes cost monitoring, exposing cloud provider pricing and workload resource allocation as Prometheus metrics. -Radar discovers if OpenCost metrics are available in the already-discovered Prometheus. If OpenCost is installed and scraping into Prometheus, cost data appears automatically with no additional configuration. The integration is passive and read-only. +Radar discovers if OpenCost metrics are available in the already-discovered Prometheus. If OpenCost is installed and scraping into Prometheus, cost data appears automatically. The integration is passive and read-only. + +OpenCost's Prometheus metrics contain numeric values but no currency metadata. Radar labels them as USD by default. If OpenCost is configured for another currency, set Radar's `--opencost-currency` flag to the matching ISO 4217 code (Helm: `cost.currency`). Radar labels the values but does not convert them. ### What Radar Shows @@ -1186,7 +1188,7 @@ Radar discovers if OpenCost metrics are available in the already-discovered Prom 1. OpenCost (or Kubecost) deployed in your cluster, with its metrics being scraped by Prometheus -OpenCost cost data is not CRD-based — no custom resources are required. Cost views appear automatically when metrics are detected; they are hidden when no OpenCost metrics are found in Prometheus. +OpenCost cost data is not CRD-based — no custom resources are required. Cost views appear automatically when metrics are detected; they are hidden when no OpenCost metrics are found in Prometheus. Non-USD values require the currency label configuration described above. --- diff --git a/go.mod b/go.mod index e59b94f5e..68dfd5c41 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 + golang.org/x/text v0.41.0 google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af helm.sh/helm/v3 v3.21.3 @@ -157,7 +158,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 7c511b6ff..87729c1b1 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -59,6 +59,7 @@ type AppConfig struct { TimelineRetention time.Duration TimelineMaxSizeBytes int64 PrometheusURL string + OpenCostCurrency string PrometheusHeaders map[string]string PrometheusHeadersFromEnv map[string]string BeylaJobSelector string @@ -275,6 +276,7 @@ func CreateServer(cfg AppConfig) *server.Server { TimelineMaxSize: fmt.Sprintf("%d", cfg.TimelineMaxSizeBytes), HistoryLimit: cfg.HistoryLimit, PrometheusURL: cfg.PrometheusURL, + OpenCostCurrency: cfg.OpenCostCurrency, PrometheusHeaders: cfg.PrometheusHeaders, PrometheusHeadersFromEnv: cfg.PrometheusHeadersFromEnv, DebugImage: cfg.DebugImage, @@ -292,6 +294,7 @@ func CreateServer(cfg AppConfig) *server.Server { StaticFS: static.FS, StaticRoot: "dist", EffectiveConfig: effectiveCfg, + OpenCostCurrency: cfg.OpenCostCurrency, DiagConfig: &server.DiagConfig{ Port: cfg.Port, DevMode: cfg.DevMode, @@ -300,6 +303,7 @@ func CreateServer(cfg AppConfig) *server.Server { HistoryLimit: cfg.HistoryLimit, DebugEvents: cfg.DebugEvents, MCPEnabled: cfg.MCPEnabled, + OpenCostCurrency: cfg.OpenCostCurrency, HasPrometheusURL: cfg.PrometheusURL != "", HasPrometheusHeaders: len(cfg.PrometheusHeaders) > 0, }, diff --git a/internal/config/config.go b/internal/config/config.go index c529902ac..a97e0c697 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ type Config struct { TimelineMaxSize string `json:"timelineMaxSize,omitempty"` // Byte size (e.g. "800Mi", "8Gi"); "0" disables HistoryLimit int `json:"historyLimit,omitempty"` PrometheusURL string `json:"prometheusUrl,omitempty"` + OpenCostCurrency string `json:"opencostCurrency,omitempty"` // PrometheusHeaders are sent with every request to the Prometheus API. // Required for auth-protected backends (Bearer tokens, X-Scope-OrgID, etc.). // Stored in plain text in ~/.radar/config.json — protect the file accordingly. diff --git a/internal/config/opencost.go b/internal/config/opencost.go new file mode 100644 index 000000000..8771e51f4 --- /dev/null +++ b/internal/config/opencost.go @@ -0,0 +1,25 @@ +package config + +import ( + "fmt" + "strings" + + "golang.org/x/text/currency" +) + +const defaultOpenCostCurrency = "USD" + +func NormalizeOpenCostCurrency(raw string) (string, error) { + code := strings.ToUpper(strings.TrimSpace(raw)) + if code == "" { + return defaultOpenCostCurrency, nil + } + if code == "XXX" || code == "XTS" { + return "", fmt.Errorf("must be a monetary ISO 4217 currency code") + } + unit, err := currency.ParseISO(code) + if err != nil { + return "", fmt.Errorf("must be a recognized ISO 4217 currency code: %w", err) + } + return unit.String(), nil +} diff --git a/internal/config/opencost_test.go b/internal/config/opencost_test.go new file mode 100644 index 000000000..bd0d6bd97 --- /dev/null +++ b/internal/config/opencost_test.go @@ -0,0 +1,31 @@ +package config + +import "testing" + +func TestNormalizeOpenCostCurrency(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "default", want: "USD"}, + {name: "trim and uppercase", raw: " gbp ", want: "GBP"}, + {name: "zero decimal currency", raw: "jpy", want: "JPY"}, + {name: "unknown", raw: "ZZZ", wantErr: true}, + {name: "malformed", raw: "EURO", wantErr: true}, + {name: "no currency", raw: "XXX", wantErr: true}, + {name: "testing code", raw: "XTS", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeOpenCostCurrency(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("NormalizeOpenCostCurrency(%q) error = %v, wantErr %v", tt.raw, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("NormalizeOpenCostCurrency(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/internal/opencost/handlers.go b/internal/opencost/handlers.go index a2b0eaa55..74f395ca3 100644 --- a/internal/opencost/handlers.go +++ b/internal/opencost/handlers.go @@ -17,27 +17,31 @@ import ( ) // RegisterRoutes registers OpenCost routes on the given router. -func RegisterRoutes(r chi.Router) { - r.Get("/opencost/summary", handleSummary) - r.Get("/opencost/workloads", handleWorkloads) - r.Get("/opencost/trend", handleTrend) - r.Get("/opencost/nodes", handleNodes) +func RegisterRoutes(r chi.Router, currency string) { + if currency == "" { + currency = pkgopencost.DefaultCurrency + } + r.Get("/opencost/summary", func(w http.ResponseWriter, r *http.Request) { handleSummary(w, r, currency) }) + r.Get("/opencost/workloads", func(w http.ResponseWriter, r *http.Request) { handleWorkloads(w, r, currency) }) + r.Get("/opencost/trend", func(w http.ResponseWriter, r *http.Request) { handleTrend(w, r, currency) }) + r.Get("/opencost/nodes", func(w http.ResponseWriter, r *http.Request) { handleNodes(w, r, currency) }) } // handleSummary returns namespace-level cost summary from OpenCost Prometheus metrics. -func handleSummary(w http.ResponseWriter, r *http.Request) { +func handleSummary(w http.ResponseWriter, r *http.Request, currency string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: pkgopencost.ReasonNoPrometheus}) + writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (summary): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: ConnectionFailureReason(err)}) + writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: ConnectionFailureReason(err), Currency: currency}) return } - writeJSON(w, http.StatusOK, pkgopencost.ComputeCostSummaryFromProm( - r.Context(), client.Prom(), pkgopencost.SummaryOptions{})) + resp := pkgopencost.ComputeCostSummaryFromProm( + r.Context(), client.Prom(), pkgopencost.SummaryOptions{Currency: currency}) + writeJSON(w, http.StatusOK, resp) } func writeJSON(w http.ResponseWriter, status int, v interface{}) { @@ -49,7 +53,7 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) { } // handleWorkloads returns workload-level cost breakdown for a namespace. -func handleWorkloads(w http.ResponseWriter, r *http.Request) { +func handleWorkloads(w http.ResponseWriter, r *http.Request, currency string) { ns := r.URL.Query().Get("namespace") if ns == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "namespace parameter is required"}) @@ -58,17 +62,18 @@ func handleWorkloads(w http.ResponseWriter, r *http.Request) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: pkgopencost.ReasonNoPrometheus}) + writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (workloads): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: ConnectionFailureReason(err)}) + writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: ConnectionFailureReason(err), Currency: currency}) return } - writeJSON(w, http.StatusOK, pkgopencost.ComputeWorkloadsFromProm( - r.Context(), client.Prom(), ns, BuildPodOwnerLookup(ns))) + resp := pkgopencost.ComputeWorkloadsFromProm(r.Context(), client.Prom(), ns, BuildPodOwnerLookup(ns)) + resp.Currency = currency + writeJSON(w, http.StatusOK, resp) } // BuildPodOwnerLookup snapshots radar's pod informer for `ns` so @@ -117,34 +122,36 @@ func stripReplicaSetSuffix(name string) string { } // handleTrend returns cost trend data over time as a stacked series per namespace. -func handleTrend(w http.ResponseWriter, r *http.Request) { +func handleTrend(w http.ResponseWriter, r *http.Request, currency string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus}) + writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (trend): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: ConnectionFailureReason(err)}) + writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: currency}) return } - writeJSON(w, http.StatusOK, pkgopencost.ComputeCostTrendFromProm( - r.Context(), client.Prom(), pkgopencost.TrendPromOptions{Range: r.URL.Query().Get("range")})) + resp := pkgopencost.ComputeCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.TrendPromOptions{Range: r.URL.Query().Get("range")}) + resp.Currency = currency + writeJSON(w, http.StatusOK, resp) } // handleNodes returns per-node cost breakdown. -func handleNodes(w http.ResponseWriter, r *http.Request) { +func handleNodes(w http.ResponseWriter, r *http.Request, currency string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus}) + writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (nodes): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: ConnectionFailureReason(err)}) + writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: currency}) return } resp := pkgopencost.ComputeNodeCosts(r.Context(), client.Prom()) + resp.Currency = currency attachNodeProviderIDs(resp) writeJSON(w, http.StatusOK, resp) } diff --git a/internal/opencost/handlers_test.go b/internal/opencost/handlers_test.go new file mode 100644 index 000000000..dc124aebc --- /dev/null +++ b/internal/opencost/handlers_test.go @@ -0,0 +1,95 @@ +package opencost + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + prometheuspkg "github.com/skyhook-io/radar/internal/prometheus" +) + +func TestUnavailableResponsesIncludeCurrency(t *testing.T) { + tests := []struct { + name string + target string + handler func(http.ResponseWriter, *http.Request, string) + }{ + {name: "summary", target: "/opencost/summary", handler: handleSummary}, + {name: "workloads", target: "/opencost/workloads?namespace=default", handler: handleWorkloads}, + {name: "trend", target: "/opencost/trend", handler: handleTrend}, + {name: "nodes", target: "/opencost/nodes", handler: handleNodes}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.target, nil) + w := httptest.NewRecorder() + tt.handler(w, req, "GBP") + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var body struct { + Currency string `json:"currency"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Currency != "GBP" { + t.Errorf("currency = %q, want GBP", body.Currency) + } + }) + } +} + +func TestConnectedResponsesIncludeCurrency(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("query") == "up" { + _, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{"job":"prometheus"},"value":[1700000000,"1"]}]}}`)) + return + } + resultType := "vector" + if r.URL.Path == "/api/v1/query_range" { + resultType = "matrix" + } + _, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"` + resultType + `","result":[]}}`)) + })) + prometheuspkg.Initialize(nil, nil, "test") + prometheuspkg.SetManualURL(srv.URL) + t.Cleanup(func() { + srv.Close() + prometheuspkg.Reset() + prometheuspkg.Initialize(nil, nil, "") + }) + + tests := []struct { + name string + target string + handler func(http.ResponseWriter, *http.Request, string) + }{ + {name: "summary", target: "/opencost/summary", handler: handleSummary}, + {name: "workloads", target: "/opencost/workloads?namespace=default", handler: handleWorkloads}, + {name: "trend", target: "/opencost/trend", handler: handleTrend}, + {name: "nodes", target: "/opencost/nodes", handler: handleNodes}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + tt.handler(w, httptest.NewRequest(http.MethodGet, tt.target, nil), "GBP") + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var body struct { + Currency string `json:"currency"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Currency != "GBP" { + t.Errorf("currency = %q, want GBP", body.Currency) + } + }) + } +} diff --git a/internal/server/diagnostics.go b/internal/server/diagnostics.go index d130141df..2dca0d6c3 100644 --- a/internal/server/diagnostics.go +++ b/internal/server/diagnostics.go @@ -29,6 +29,7 @@ type DiagConfig struct { HistoryLimit int `json:"historyLimit"` DebugEvents bool `json:"debugEvents"` MCPEnabled bool `json:"mcpEnabled"` + OpenCostCurrency string `json:"opencostCurrency"` HasPrometheusURL bool `json:"hasPrometheusURL"` HasPrometheusHeaders bool `json:"hasPrometheusHeaders"` } diff --git a/internal/server/opencost_application.go b/internal/server/opencost_application.go index 79a1ac09b..70c0f9881 100644 --- a/internal/server/opencost_application.go +++ b/internal/server/opencost_application.go @@ -31,12 +31,16 @@ func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Reques client := prometheuspkg.GetClient() if client == nil { - s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, pkgopencost.ReasonNoPrometheus)) + resp := pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, pkgopencost.ReasonNoPrometheus) + resp.Currency = s.openCostCurrency + s.writeJSON(w, resp) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Print("[opencost] EnsureConnected failed for application cost") - s.writeJSON(w, pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, internalopencost.ConnectionFailureReason(err))) + resp := pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, internalopencost.ConnectionFailureReason(err)) + resp.Currency = s.openCostCurrency + s.writeJSON(w, resp) return } @@ -46,7 +50,9 @@ func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Reques r.Context(), client.Prom(), namespace, internalopencost.BuildPodOwnerLookup(namespace)) } - s.writeJSON(w, pkgopencost.BuildApplicationCostResponse(inputs, unavailable, unsupported, namespaceCosts)) + resp := pkgopencost.BuildApplicationCostResponse(inputs, unavailable, unsupported, namespaceCosts) + resp.Currency = s.openCostCurrency + s.writeJSON(w, resp) } func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.Request) { @@ -66,29 +72,35 @@ func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.R client := prometheuspkg.GetClient() if client == nil { - s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ + resp := pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ Range: req.Range, Workloads: refs, Unavailable: unavailable, - })) + }) + resp.Currency = s.openCostCurrency + s.writeJSON(w, resp) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Print("[opencost] EnsureConnected failed for application trend") - s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ + resp := pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), nil, pkgopencost.ApplicationTrendOptions{ Range: req.Range, Workloads: refs, Unavailable: unavailable, UnavailableReason: internalopencost.ConnectionFailureReason(err), - })) + }) + resp.Currency = s.openCostCurrency + s.writeJSON(w, resp) return } - s.writeJSON(w, pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.ApplicationTrendOptions{ + resp := pkgopencost.ComputeApplicationCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.ApplicationTrendOptions{ Range: req.Range, Workloads: refs, Unavailable: unavailable, - })) + }) + resp.Currency = s.openCostCurrency + s.writeJSON(w, resp) } func (s *Server) parseOpenCostApplicationRequest(w http.ResponseWriter, r *http.Request) (openCostApplicationRequest, []pkgopencost.ApplicationWorkloadCostInput, []pkgopencost.ApplicationWorkloadStatus, []pkgopencost.ApplicationWorkloadRef, bool) { diff --git a/internal/server/opencost_workload.go b/internal/server/opencost_workload.go index e86b507f1..d8d4af91e 100644 --- a/internal/server/opencost_workload.go +++ b/internal/server/opencost_workload.go @@ -39,6 +39,7 @@ func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) Namespace: namespace, Kind: kind, Name: name, + Currency: s.openCostCurrency, } client := prometheuspkg.GetClient() @@ -57,7 +58,9 @@ func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) } workloads := pkgopencost.ComputeWorkloadsFromProm(r.Context(), client.Prom(), namespace, internalopencost.BuildPodOwnerLookup(namespace)) - s.writeJSON(w, focusOpenCostWorkload(workloads, kind, namespace, name, desiredReplicas)) + result := focusOpenCostWorkload(workloads, kind, namespace, name, desiredReplicas) + result.Currency = s.openCostCurrency + s.writeJSON(w, result) } func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Request) { @@ -78,6 +81,7 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ Kind: kind, Name: name, Range: r.URL.Query().Get("range"), + Currency: s.openCostCurrency, } client := prometheuspkg.GetClient() @@ -95,12 +99,14 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ return } - s.writeJSON(w, pkgopencost.ComputeWorkloadCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.WorkloadTrendOptions{ + result := pkgopencost.ComputeWorkloadCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.WorkloadTrendOptions{ Range: r.URL.Query().Get("range"), Namespace: namespace, Kind: kind, Name: name, - })) + }) + result.Currency = s.openCostCurrency + s.writeJSON(w, result) } func (s *Server) parseOpenCostWorkloadRequest(w http.ResponseWriter, r *http.Request) (kind, namespace, name string, ok bool) { diff --git a/internal/server/opencost_workload_test.go b/internal/server/opencost_workload_test.go index 887a82336..c98cded43 100644 --- a/internal/server/opencost_workload_test.go +++ b/internal/server/opencost_workload_test.go @@ -1,15 +1,84 @@ package server import ( + "context" + "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/go-chi/chi/v5" internalopencost "github.com/skyhook-io/radar/internal/opencost" prometheuspkg "github.com/skyhook-io/radar/internal/prometheus" pkgopencost "github.com/skyhook-io/radar/pkg/opencost" ) +func TestOpenCostDetailResponsesIncludeCurrencyWhenUnavailable(t *testing.T) { + s := &Server{openCostCurrency: "GBP"} + tests := []struct { + name string + method string + target string + body string + serve func(http.ResponseWriter, *http.Request) + params map[string]string + }{ + { + name: "workload current", method: http.MethodGet, + target: "/api/opencost/workload/Deployment/default/nginx", + serve: s.handleOpenCostWorkload, + params: map[string]string{"kind": "Deployment", "namespace": "default", "name": "nginx"}, + }, + { + name: "workload trend", method: http.MethodGet, + target: "/api/opencost/workload/Deployment/default/nginx/trend?range=24h", + serve: s.handleOpenCostWorkloadTrend, + params: map[string]string{"kind": "Deployment", "namespace": "default", "name": "nginx"}, + }, + { + name: "application current", method: http.MethodPost, + target: "/api/opencost/application", + body: `{"workloads":[{"kind":"Deployment","namespace":"default","name":"nginx"}]}`, + serve: s.handleOpenCostApplication, + }, + { + name: "application trend", method: http.MethodPost, + target: "/api/opencost/application/trend", + body: `{"range":"24h","workloads":[{"kind":"Deployment","namespace":"default","name":"nginx"}]}`, + serve: s.handleOpenCostApplicationTrend, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body)) + if len(tt.params) > 0 { + rctx := chi.NewRouteContext() + for key, value := range tt.params { + rctx.URLParams.Add(key, value) + } + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + } + w := httptest.NewRecorder() + tt.serve(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var body struct { + Currency string `json:"currency"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Currency != "GBP" { + t.Errorf("currency = %q, want GBP", body.Currency) + } + }) + } +} + func TestOpenCostConnectionFailureReason(t *testing.T) { if got := internalopencost.ConnectionFailureReason(fmt.Errorf("wrapped: %w", prometheuspkg.ErrPrometheusNotFound)); got != pkgopencost.ReasonNoPrometheus { t.Fatalf("not-found reason = %q, want %q", got, pkgopencost.ReasonNoPrometheus) diff --git a/internal/server/server.go b/internal/server/server.go index c3075552c..03661c588 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -58,6 +58,7 @@ import ( "github.com/skyhook-io/radar/pkg/conditions" "github.com/skyhook-io/radar/pkg/hpadiag" "github.com/skyhook-io/radar/pkg/k8score" + pkgopencost "github.com/skyhook-io/radar/pkg/opencost" "github.com/skyhook-io/radar/pkg/perfstats" "github.com/skyhook-io/radar/pkg/rbac" topology "github.com/skyhook-io/radar/pkg/topology" @@ -82,6 +83,7 @@ type Server struct { mcpReadOnlyHandler http.Handler diagConfig *DiagConfig effectiveConfig *config.Config // running config for GET /api/config + openCostCurrency string authConfig auth.Config permCache *auth.PermissionCache oidcHandler *auth.OIDCHandler @@ -166,6 +168,7 @@ type Config struct { MCPReadOnlyHandler http.Handler // read-only MCP handler (read tools only) DiagConfig *DiagConfig // Sanitized config for diagnostics endpoint EffectiveConfig *config.Config // Running startup config for GET /api/config + OpenCostCurrency string // ISO 4217 code labeling values returned by OpenCost endpoints AuthConfig auth.Config // Authentication configuration AIHistoryDB string // AI run-history SQLite path ("" = memory-only runs) CloudConnect CloudConnectConfig @@ -174,6 +177,9 @@ type Config struct { // New creates a new server instance func New(cfg Config) *Server { cfg.AuthConfig.Defaults() + if cfg.OpenCostCurrency == "" { + cfg.OpenCostCurrency = pkgopencost.DefaultCurrency + } basePath, err := NormalizeBasePath(cfg.BasePath) if err != nil { log.Fatalf("Invalid base path %q: %v", cfg.BasePath, err) @@ -198,6 +204,7 @@ func New(cfg Config) *Server { mcpReadOnlyHandler: cfg.MCPReadOnlyHandler, diagConfig: cfg.DiagConfig, effectiveConfig: cfg.EffectiveConfig, + openCostCurrency: cfg.OpenCostCurrency, authConfig: cfg.AuthConfig, cloudConnectCfg: cfg.CloudConnect, topoMemo: topology.NewMemoizer(5 * time.Second), @@ -706,7 +713,7 @@ func (s *Server) setupAppRoutes(r chi.Router) { r.Post("/opencost/application/trend", s.handleOpenCostApplicationTrend) r.Get("/opencost/workload/{kind}/{namespace}/{name}", s.handleOpenCostWorkload) r.Get("/opencost/workload/{kind}/{namespace}/{name}/trend", s.handleOpenCostWorkloadTrend) - opencost.RegisterRoutes(r) + opencost.RegisterRoutes(r, s.openCostCurrency) // FluxCD routes r.Post("/flux/{kind}/{namespace}/{name}/reconcile", s.handleFluxReconcile) @@ -5185,6 +5192,12 @@ func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) { s.writeError(w, http.StatusBadRequest, "invalid request body") return } + normalizedCurrency, err := config.NormalizeOpenCostCurrency(updated.OpenCostCurrency) + if err != nil { + s.writeError(w, http.StatusBadRequest, "invalid OpenCost currency: "+err.Error()) + return + } + updated.OpenCostCurrency = normalizedCurrency result, err := config.Update(func(c *config.Config) { // Integration connection fields are owned exclusively by the live // /api/integrations/* endpoints, not this startup-config PUT. Preserve diff --git a/internal/server/settings_role_test.go b/internal/server/settings_role_test.go index 5377f0b10..063d7c299 100644 --- a/internal/server/settings_role_test.go +++ b/internal/server/settings_role_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/skyhook-io/radar/internal/auth" + "github.com/skyhook-io/radar/internal/config" ) // userWithGroups builds an authenticated user carrying the given groups, used @@ -16,6 +17,40 @@ func userWithGroups(groups ...string) *auth.User { return &auth.User{Username: "u@example.com", Groups: groups} } +func TestPutConfigPersistsHiddenOpenCostCurrency(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + s := &Server{} + r := httptest.NewRequest(http.MethodPut, "/api/config", strings.NewReader(`{"port":9280,"opencostCurrency":" gbp "}`)) + w := httptest.NewRecorder() + + s.handlePutConfig(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if got := config.Load().OpenCostCurrency; got != "GBP" { + t.Errorf("opencostCurrency = %q, want GBP", got) + } +} + +func TestPutConfigRejectsInvalidOpenCostCurrency(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + s := &Server{} + r := httptest.NewRequest(http.MethodPut, "/api/config", strings.NewReader(`{"opencostCurrency":"EURO"}`)) + w := httptest.NewRecorder() + + s.handlePutConfig(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if got := config.Load().OpenCostCurrency; got != "" { + t.Errorf("opencostCurrency = %q, want config unchanged", got) + } +} + func putConfigStatus(t *testing.T, user *auth.User) (int, string) { t.Helper() // Redirect config persistence to a temp HOME so pass-through cases that diff --git a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx index e6daf08d7..f6e0bbf83 100644 --- a/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx +++ b/packages/k8s-ui/src/components/applications/ApplicationDetail.tsx @@ -13,7 +13,7 @@ import { Boxes, ChevronDown, Clock3, - DollarSign, + Coins, ExternalLink, GitCommit, Layers, @@ -988,7 +988,7 @@ function ApplicationViewTabs({ : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary", )} > - + Cost )} diff --git a/packages/k8s-ui/src/components/workload/WorkloadView.tsx b/packages/k8s-ui/src/components/workload/WorkloadView.tsx index b7aa1042f..12cc9fb6d 100644 --- a/packages/k8s-ui/src/components/workload/WorkloadView.tsx +++ b/packages/k8s-ui/src/components/workload/WorkloadView.tsx @@ -30,7 +30,7 @@ import { BarChart3, Network, Stethoscope, - DollarSign, + Coins, } from 'lucide-react' import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types' import type { GitOpsStatus } from '../../types/gitops' @@ -722,7 +722,7 @@ export function WorkloadView({ icon: , hidden: !(renderDiagnoseTab && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)), }, - { id: 'cost', label: 'Cost', icon: , hidden: !costTabVisible }, + { id: 'cost', label: 'Cost', icon: , hidden: !costTabVisible }, { id: 'yaml', label: 'YAML', icon: }, ] const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden) diff --git a/pkg/opencost/compute.go b/pkg/opencost/compute.go index 6fee41fc9..611a3cd28 100644 --- a/pkg/opencost/compute.go +++ b/pkg/opencost/compute.go @@ -99,7 +99,7 @@ type SummaryOptions struct { // - Numbers rounded to 4dp for JSON cleanliness. func ComputeCostSummary(ctx context.Context, client *RESTClient, opts SummaryOptions) *CostSummary { if opts.Currency == "" { - opts.Currency = "USD" + opts.Currency = DefaultCurrency } if opts.Window == "" { opts.Window = "1h" @@ -339,11 +339,11 @@ func safeRatio(num, den float64) float64 { // the typed reason to the UI. // - Numbers are rounded to 4 decimal places for cleaner JSON. func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts SummaryOptions) *CostSummary { - if client == nil { - return &CostSummary{Available: false, Reason: ReasonNoPrometheus} - } if opts.Currency == "" { - opts.Currency = "USD" + opts.Currency = DefaultCurrency + } + if client == nil { + return &CostSummary{Available: false, Reason: ReasonNoPrometheus, Currency: opts.Currency} } if opts.Window == "" { opts.Window = "1h" @@ -357,7 +357,7 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S `sum by (namespace) (label_replace(rate(opencost_container_cpu_cost_total[1h]), "namespace", "$1", "exported_namespace", "(.+)"))`) if err != nil { log.Printf("[opencost] CPU allocation fallback query also failed: %v", err) - return &CostSummary{Available: false, Reason: ReasonQueryError} + return &CostSummary{Available: false, Reason: ReasonQueryError, Currency: opts.Currency} } } @@ -369,12 +369,12 @@ func ComputeCostSummaryFromProm(ctx context.Context, client *prom.Client, opts S `sum by (namespace) (label_replace(rate(opencost_container_memory_cost_total[1h]), "namespace", "$1", "exported_namespace", "(.+)"))`) if err != nil { log.Printf("[opencost] memory allocation fallback query also failed: %v", err) - return &CostSummary{Available: false, Reason: ReasonQueryError} + return &CostSummary{Available: false, Reason: ReasonQueryError, Currency: opts.Currency} } } if len(cpuResult.Series) == 0 && len(memResult.Series) == 0 { - return &CostSummary{Available: false, Reason: ReasonNoMetrics} + return &CostSummary{Available: false, Reason: ReasonNoMetrics, Currency: opts.Currency} } // Usage queries are best-effort: efficiency / idle are derived from them diff --git a/pkg/opencost/compute_test.go b/pkg/opencost/compute_test.go index 34843e06d..3f1f98eb6 100644 --- a/pkg/opencost/compute_test.go +++ b/pkg/opencost/compute_test.go @@ -101,12 +101,12 @@ func TestComputeCostSummary_HappyPath(t *testing.T) { {contains: "node_total_hourly_cost", body: scalarBody(8.0)}, // exceeds sum of namespaces, so it wins }) - got := ComputeCostSummaryFromProm(context.Background(), client, SummaryOptions{}) + got := ComputeCostSummaryFromProm(context.Background(), client, SummaryOptions{Currency: "GBP"}) if !got.Available { t.Fatalf("summary unavailable: %+v", got) } - if got.Currency != "USD" || got.Window != "1h" { - t.Errorf("currency/window defaults: %+v", got) + if got.Currency != "GBP" || got.Window != "1h" { + t.Errorf("currency/window: %+v", got) } if got.TotalHourlyCost != 8.0 { t.Errorf("TotalHourlyCost=%v, want 8.0 (node_total_hourly_cost ceiling)", got.TotalHourlyCost) @@ -135,6 +135,13 @@ func TestComputeCostSummary_HappyPath(t *testing.T) { } } +func TestComputeCostSummary_NilClientIncludesCurrency(t *testing.T) { + got := ComputeCostSummaryFromProm(context.Background(), nil, SummaryOptions{Currency: "GBP"}) + if got.Available || got.Reason != ReasonNoPrometheus || got.Currency != "GBP" { + t.Fatalf("unexpected unavailable summary: %+v", got) + } +} + func TestComputeCostSummary_NoMetricsReason(t *testing.T) { client := scriptedProm(t, []scriptedCase{ // All queries return empty vector results. diff --git a/pkg/opencost/trend.go b/pkg/opencost/trend.go index 53d418b12..7ac390c6a 100644 --- a/pkg/opencost/trend.go +++ b/pkg/opencost/trend.go @@ -36,7 +36,7 @@ type TrendOptions struct { // — always including a "__total__" aggregate — ordered by bucket // timestamp ascending. // -// Each data point's Value is normalized to $/hr for the bucket (OpenCost's +// Each data point's Value is normalized to cost per hour for the bucket (OpenCost's // per-bucket totalCost ÷ bucket duration), matching the hourly-rate // convention used throughout the Costs UI. The UI multiplies by 730 for // monthly projections or hours-in-period for retrospective totals. @@ -95,7 +95,7 @@ func ComputeCostTrend(ctx context.Context, client *RESTClient, opts TrendOptions } // Normalize to hourly rate for this bucket. OpenCost returns // totalCost summed across the bucket; dividing by bucket - // duration (hours) gives the $/hr rate the UI consumes. + // duration (hours) gives the hourly rate the UI consumes. value := a.TotalCost / bucketHours seriesByName[name] = append(seriesByName[name], CostDataPoint{ Timestamp: ts, diff --git a/pkg/opencost/types.go b/pkg/opencost/types.go index 9e5f5300d..42fe3e53a 100644 --- a/pkg/opencost/types.go +++ b/pkg/opencost/types.go @@ -3,6 +3,7 @@ package opencost // Unavailability reasons — returned in the "reason" field when available=false // so the frontend can show contextual guidance to the user. const ( + DefaultCurrency = "USD" 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 @@ -46,6 +47,7 @@ type NamespaceCost struct { type WorkloadCostResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Namespace string `json:"namespace"` Workloads []WorkloadCost `json:"workloads"` } @@ -53,6 +55,7 @@ type WorkloadCostResponse struct { type WorkloadCostDetailResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Namespace string `json:"namespace"` Kind string `json:"kind"` Name string `json:"name"` @@ -81,6 +84,7 @@ type WorkloadCost struct { type CostTrendResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Range string `json:"range"` Series []CostTrendSeries `json:"series,omitempty"` } @@ -88,6 +92,7 @@ type CostTrendResponse struct { type WorkloadCostTrendResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Namespace string `json:"namespace"` Kind string `json:"kind"` Name string `json:"name"` @@ -144,6 +149,7 @@ type ApplicationWorkloadCost struct { type ApplicationCostResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Partial bool `json:"partial,omitempty"` Totals ApplicationCostTotals `json:"totals"` Coverage ApplicationCostCoverage `json:"coverage"` @@ -159,6 +165,7 @@ type ApplicationCostTrendSeries struct { type ApplicationCostTrendResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Range string `json:"range"` Partial bool `json:"partial,omitempty"` WindowTotalCost float64 `json:"windowTotalCost,omitempty"` @@ -183,6 +190,7 @@ type CostDataPoint struct { type NodeCostResponse struct { Available bool `json:"available"` Reason string `json:"reason,omitempty"` + Currency string `json:"currency,omitempty"` Nodes []NodeCost `json:"nodes,omitempty"` } diff --git a/scripts/test-chart.sh b/scripts/test-chart.sh index a50a05bc0..1a31a9782 100755 --- a/scripts/test-chart.sh +++ b/scripts/test-chart.sh @@ -59,6 +59,11 @@ assert_not_contains '^kind: RoleBinding$' "no namespaced RoleBinding" assert_not_contains 'radar-self-upgrade' "no self-upgrade Role/RoleBinding" assert_contains 'MY_POD_NAMESPACE' "identity ships for read-only self-description" assert_contains 'MY_DEPLOYMENT_NAME' "identity ships for read-only self-description" +assert_not_contains '--opencost-currency=' "default OpenCost currency flag omitted" +echo + +render "cost.currency — explicit OpenCost currency label" --set cost.currency=GBP +assert_contains '--opencost-currency=GBP' "OpenCost currency flag rendered" echo render "prometheusHeadersFromEnv — flag and secret env stay separate" \ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8d995fe5b..8218cce6b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -937,6 +937,7 @@ export interface OpenCostWorkloadCost { export interface OpenCostWorkloadResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; namespace: string; workloads: OpenCostWorkloadCost[]; } @@ -964,6 +965,7 @@ export function useOpenCostWorkloads( export interface OpenCostWorkloadDetailResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; namespace: string; kind: string; name: string; @@ -1008,6 +1010,7 @@ export interface OpenCostTrendSeries { export interface OpenCostTrendResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; range: string; series?: OpenCostTrendSeries[]; } @@ -1029,6 +1032,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") { export interface OpenCostWorkloadTrendResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; namespace: string; kind: string; name: string; @@ -1101,6 +1105,7 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork export interface OpenCostApplicationCostResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; partial?: boolean; totals: OpenCostApplicationCostTotals; coverage: OpenCostApplicationCostCoverage; @@ -1115,6 +1120,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW export interface OpenCostApplicationCostTrendResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; range: string; partial?: boolean; windowTotalCost?: number; @@ -1206,6 +1212,7 @@ export interface OpenCostNodeCost { export interface OpenCostNodeResponse { available: boolean; reason?: CostUnavailableReason; + currency?: string; nodes?: OpenCostNodeCost[]; } diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index 82b92a58b..f82df8890 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react' +import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-react' import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui' import { COST_DISCOVERY_GRACE_MS, @@ -15,6 +15,7 @@ import { import { Tooltip } from '../ui/Tooltip' import { ChartLegend, CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart' import { + DEFAULT_COST_CURRENCY, formatCostPerHour, formatHistoricalSpend, formatProjectedDailyRate, @@ -148,6 +149,8 @@ export function ApplicationCostTab({ 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) + const currentCurrency = current?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY + const trendCurrency = trend?.currency ?? current?.currency ?? DEFAULT_COST_CURRENCY return (
@@ -182,11 +185,11 @@ export function ApplicationCostTab({
Application compute cost
- +
- OpenCost CPU and memory allocation rate ($/hr) for Deployment, StatefulSet, and - DaemonSet workloads + OpenCost CPU and memory allocation rate ({trendCurrency}/hr) for Deployment, + StatefulSet, and DaemonSet workloads
@@ -201,6 +204,7 @@ export function ApplicationCostTab({ points.length, trend?.windowTotalCost ?? 0, trendLoading || state === 'partial_missing_history', + trendCurrency, )} subvalue={ state === 'partial_missing_history' @@ -210,10 +214,10 @@ export function ApplicationCostTab({ /> @@ -226,7 +230,7 @@ export function ApplicationCostTab({ ) : hasTrend && chartSeries.length > 0 ? (
- +
) : ( @@ -250,16 +254,17 @@ export function ApplicationCostTab({ /> onSelectWorkloadCost(appWorkload) @@ -309,9 +315,13 @@ export function ApplicationCostTab({
- 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. + Powered by OpenCost via Prometheus.{' '} + {currentCurrency !== DEFAULT_COST_CURRENCY && ( + <>Radar labels OpenCost values as {currentCurrency}; no currency conversion. + )} + 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.
) @@ -361,10 +371,12 @@ export function applicationCostWorkloads(workloads: AppWorkload[]): AppWorkload[ function ApplicationWorkloadCostRow({ row, maxCost, + currency, onOpen, }: { row: OpenCostApplicationWorkloadCost maxCost: number + currency: string onOpen?: () => void }) { const current = row.current @@ -392,10 +404,10 @@ function ApplicationWorkloadCostRow({ )}
- {current ? formatProjectedMonthlyRate(hourly) : '—'} + {current ? formatProjectedMonthlyRate(hourly, currency) : '—'}
- {current ? formatCostPerHour(hourly) : '—'} + {current ? formatCostPerHour(hourly, currency) : '—'}
{current - ? `${formatProjectedMonthlyCost(current.cpuCost)} / ${formatProjectedMonthlyCost(current.memoryCost)}` + ? `${formatProjectedMonthlyCost(current.cpuCost, currency)} / ${formatProjectedMonthlyCost(current.memoryCost, currency)}` : '—'}
@@ -512,7 +524,7 @@ function ApplicationCostUnavailable({ return (
- +
{text}
diff --git a/web/src/components/cost/CostTrendChart.tsx b/web/src/components/cost/CostTrendChart.tsx index 7a3823e75..e772e582c 100644 --- a/web/src/components/cost/CostTrendChart.tsx +++ b/web/src/components/cost/CostTrendChart.tsx @@ -2,7 +2,7 @@ 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 { formatCostAxis, formatCostPerHour } from './format' +import { DEFAULT_COST_CURRENCY, formatCostAxis, formatCostPerHour } from './format' const SERIES_COLORS = [ '#3b82f6', // blue-500 @@ -41,6 +41,8 @@ export function CostTrendChart() { return null } + const currency = data.currency ?? DEFAULT_COST_CURRENCY + return (
@@ -48,20 +50,28 @@ export function CostTrendChart() {
Cost rate trend
-
Historical OpenCost allocation rate ($/hr)
+
+ Historical OpenCost allocation rate ({currency}/hr) +
- +
) } -export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) { +export function StackedAreaChart({ + series, + currency, +}: { + series: OpenCostTrendSeries[] + currency: string +}) { const svgRef = useRef(null) const [hoverX, setHoverX] = useState(null) @@ -123,7 +133,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) const tickCount = 4 const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => { const val = (yMax / tickCount) * i - return { val, y: toY(val), label: formatCostAxis(val) } + return { val, y: toY(val), label: formatCostAxis(val, currency) } }) // X axis ticks @@ -175,7 +185,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) xTicks, paths, } - }, [series, plotHeight, plotWidth]) + }, [series, currency, plotHeight, plotWidth]) // Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally) const hoverData = useMemo(() => { @@ -341,14 +351,14 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
{p.namespace} - {formatCostTooltip(p.value)} + {formatCostTooltip(p.value, currency)}
))} {series.length > 1 && (
Total - {formatCostTooltip(hoverData.total)} + {formatCostTooltip(hoverData.total, currency)}
)}
@@ -403,8 +413,8 @@ export function CostTimeRangeSelector({ ) } -function formatCostTooltip(value: number): string { - return formatCostPerHour(value) +function formatCostTooltip(value: number, currency: string): string { + return formatCostPerHour(value, currency) } function formatTimestamp(unix: number): string { diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx index eebc1aebc..5bb266fa6 100644 --- a/web/src/components/cost/CostView.tsx +++ b/web/src/components/cost/CostView.tsx @@ -15,7 +15,7 @@ import type { import { ChevronDown, ChevronRight, - DollarSign, + Coins, ExternalLink, HelpCircle, Loader2, @@ -26,6 +26,7 @@ import { PaneLoader, FreshnessControl, PageHeader } from '@skyhook-io/k8s-ui' import { CostTrendChart } from './CostTrendChart' import { COST_HOURS_PER_MONTH, + DEFAULT_COST_CURRENCY, formatCostPerHour, formatProjectedDailyRate, formatProjectedMonthlyCost, @@ -125,7 +126,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) {
- +

{message}

@@ -439,7 +455,13 @@ function NamespaceCostRow({ {expanded && isSystemCostNamespace(ns.name) && ( )} - {expanded && } + {expanded && ( + + )}
) } @@ -471,9 +493,11 @@ function SystemNamespacesCostNote() { function WorkloadRows({ namespace, + fallbackCurrency, onOpenResource, }: { namespace: string + fallbackCurrency: string onOpenResource?: (resource: SelectedResource) => void }) { const { data, isLoading } = useOpenCostWorkloads(namespace) @@ -488,6 +512,7 @@ function WorkloadRows({ } const workloads = data?.workloads ?? [] + const currency = data?.currency ?? fallbackCurrency if (workloads.length === 0) { return (
@@ -504,6 +529,7 @@ function WorkloadRows({ wl={wl} namespace={namespace} maxCost={workloads[0]?.hourlyCost ?? 0} + currency={currency} onOpenResource={onOpenResource} /> ))} @@ -515,11 +541,13 @@ function WorkloadCostRow({ wl, namespace, maxCost, + currency, onOpenResource, }: { wl: OpenCostWorkloadCost namespace: string maxCost: number + currency: string onOpenResource?: (resource: SelectedResource) => void }) { const cpuPct = wl.hourlyCost > 0 ? (wl.cpuCost / wl.hourlyCost) * 100 : 50 @@ -541,10 +569,10 @@ function WorkloadCostRow({ )} - {formatProjectedMonthlyCost(wl.hourlyCost)} + {formatProjectedMonthlyCost(wl.hourlyCost, currency)} - {formatCostPerHour(wl.hourlyCost)} + {formatCostPerHour(wl.hourlyCost, currency)}
- {formatProjectedMonthlyCost(wl.cpuCost)} / {formatProjectedMonthlyCost(wl.memoryCost)} + {formatProjectedMonthlyCost(wl.cpuCost, currency)} /{' '} + {formatProjectedMonthlyCost(wl.memoryCost, currency)} ) @@ -582,9 +611,11 @@ function WorkloadCostRow({ function NodeCostTable({ nodes, + currency, onOpenResource, }: { nodes: OpenCostNodeCost[] + currency: string onOpenResource?: (resource: SelectedResource) => void }) { return ( @@ -627,7 +658,12 @@ function NodeCostTable({ {/* Node rows */}
{nodes.map((node) => ( - + ))}
@@ -636,9 +672,11 @@ function NodeCostTable({ function NodeCostRow({ node, + currency, onOpenResource, }: { node: OpenCostNodeCost + currency: string onOpenResource?: (resource: SelectedResource) => void }) { const cloudLink = nodeCloudConsoleLink(node.providerID) @@ -680,13 +718,14 @@ function NodeCostRow({ {node.region && ({node.region})}
- {formatProjectedMonthlyCost(node.hourlyCost)} + {formatProjectedMonthlyCost(node.hourlyCost, currency)} - {formatCostPerHour(node.hourlyCost)} + {formatCostPerHour(node.hourlyCost, currency)} - {formatProjectedMonthlyCost(node.cpuCost)} / {formatProjectedMonthlyCost(node.memoryCost)} + {formatProjectedMonthlyCost(node.cpuCost, currency)} /{' '} + {formatProjectedMonthlyCost(node.memoryCost, currency)}
) @@ -729,7 +768,7 @@ function apiGroupForCostWorkload(kind: string): string | undefined { // --- Help dialog --- -function CostHelpDialog({ onClose }: { onClose: () => void }) { +function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () => void }) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() @@ -767,8 +806,18 @@ function CostHelpDialog({ onClose }: { onClose: () => void }) {

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. + allocation data. This gives you a cost value for each workload running on your cluster. +

+ + +
+

+ Which currency is shown? +

+

+ Values are labeled {currency}. If OpenCost prices your cluster in + another currency, start Radar with --opencost-currency (Helm:{' '} + cost.currency). Radar labels the values but does not convert them.

diff --git a/web/src/components/cost/CurrentAllocationUse.tsx b/web/src/components/cost/CurrentAllocationUse.tsx index 749e06fad..15e4779ab 100644 --- a/web/src/components/cost/CurrentAllocationUse.tsx +++ b/web/src/components/cost/CurrentAllocationUse.tsx @@ -4,6 +4,7 @@ import { Tooltip } from '../ui/Tooltip' import { formatCostPerHour, formatProjectedMonthlyRate } from './format' interface CurrentAllocationUseProps { + currency: string dataAvailable: boolean cpuCost: number memoryCost: number @@ -32,6 +33,7 @@ export function formatAllocatedUse( } export function CurrentAllocationUse({ + currency, dataAvailable, cpuCost, memoryCost, @@ -63,11 +65,11 @@ export function CurrentAllocationUse({
- {dataAvailable ? formatProjectedMonthlyRate(hourlyCost) : '—'} + {dataAvailable ? formatProjectedMonthlyRate(hourlyCost, currency) : '—'}
{dataAvailable && (
- {formatCostPerHour(hourlyCost)} current rate + {formatCostPerHour(hourlyCost, currency)} current rate
)}
@@ -86,13 +88,13 @@ export function CurrentAllocationUse({ diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index 9f6575280..4b6d366f5 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react' +import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-react' import { useOpenCostWorkload, useOpenCostWorkloadTrend, @@ -12,6 +12,7 @@ import { import { Tooltip } from '../ui/Tooltip' import { CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart' import { + DEFAULT_COST_CURRENCY, formatCostPerHour, formatHistoricalSpend, formatProjectedDailyRate, @@ -110,10 +111,13 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) const windowTotal = trend?.available ? (trend.windowTotalCost ?? 0) : 0 const cpuCost = current?.cpuCost ?? 0 const memoryCost = current?.memoryCost ?? 0 + const currentCurrency = currentQuery.data?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY + const trendCurrency = trend?.currency ?? currentQuery.data?.currency ?? DEFAULT_COST_CURRENCY const windowSpendValue = formatHistoricalSpend( points.length, windowTotal, trendLoading || state === 'partial_missing_history', + trendCurrency, ) return ( @@ -127,10 +131,11 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
Historical compute cost
- +
- OpenCost CPU and memory allocation rate ($/hr) attributed by workload ownership + OpenCost CPU and memory allocation rate ({trendCurrency}/hr) attributed by workload + ownership
@@ -148,10 +153,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) /> @@ -163,7 +168,10 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps) Loading historical cost… ) : hasTrend ? ( - + ) : (
No historical workload owner cost points for this range. @@ -195,16 +203,17 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
- 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. + Powered by OpenCost via Prometheus.{' '} + {currentCurrency !== DEFAULT_COST_CURRENCY && ( + <>Radar labels OpenCost values as {currentCurrency}; no currency conversion. + )} + Historical spend uses the selected range; projected monthly values multiply the current + hourly allocation. Storage/PVC attribution remains at namespace and cluster level.
) @@ -313,7 +325,7 @@ function WorkloadCostUnavailable({ state }: { state: CostUnavailableReason | 'lo return (
- +
{message}
diff --git a/web/src/components/cost/format.test.ts b/web/src/components/cost/format.test.ts index 8a09f0312..e7d3263b9 100644 --- a/web/src/components/cost/format.test.ts +++ b/web/src/components/cost/format.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + formatCostAxis, formatCostPerHour, formatHistoricalSpend, formatProjectedDailyRate, @@ -9,19 +10,33 @@ import { 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') + expect(formatProjectedDailyRate(0.1, 'USD')).toBe('~$2.40/day') + expect(formatProjectedMonthlyCost(1, 'USD')).toBe('~$730.00') + expect(formatProjectedMonthlyRate(0.1, 'USD')).toBe('~$73.00/mo') }) it('keeps hourly rates explicit', () => { - expect(formatCostPerHour(0.1)).toBe('$0.100/hr') + expect(formatCostPerHour(0.1, 'USD')).toBe('$0.100/hr') }) it('does not turn insufficient history into zero spend', () => { - expect(formatHistoricalSpend(1, 0, false)).toBe('—') - expect(formatHistoricalSpend(2, 0, false)).toBe('$0.00') - expect(formatHistoricalSpend(2, 1.25, false)).toBe('~$1.25') - expect(formatHistoricalSpend(2, 1.25, true)).toBe('—') + expect(formatHistoricalSpend(1, 0, false, 'USD')).toBe('—') + expect(formatHistoricalSpend(2, 0, false, 'USD')).toBe('$0.00') + expect(formatHistoricalSpend(2, 1.25, false, 'USD')).toBe('~$1.25') + expect(formatHistoricalSpend(2, 1.25, true, 'USD')).toBe('—') + }) + + it('uses the configured currency and its major-unit precision', () => { + expect(formatProjectedDailyRate(0.1, 'EUR')).toBe('~€2.40/day') + expect(formatCostPerHour(0.1, 'GBP')).toBe('£0.100/hr') + expect(formatProjectedMonthlyCost(1, 'JPY')).toBe('~¥730') + expect(formatCostPerHour(0.1, 'JPY')).toBe('¥0.100/hr') + expect(formatHistoricalSpend(2, 0, false, 'JPY')).toBe('¥0') + expect(formatCostAxis(0.000001, 'GBP')).toBe('<£0.00001') + }) + + it('normalizes codes and falls back to USD for malformed currencies', () => { + expect(formatProjectedMonthlyCost(1, ' eur ')).toBe('~€730.00') + expect(formatProjectedMonthlyCost(1, 'not-a-code')).toBe('~$730.00') }) }) diff --git a/web/src/components/cost/format.ts b/web/src/components/cost/format.ts index 3dceaf69e..dc4a6c456 100644 --- a/web/src/components/cost/format.ts +++ b/web/src/components/cost/format.ts @@ -1,46 +1,83 @@ export const COST_HOURS_PER_DAY = 24 export const COST_HOURS_PER_MONTH = 730 +export const DEFAULT_COST_CURRENCY = 'USD' -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' +const currencyFormatters = new Map() + +function currencyFormatter(currency: string, digits?: number): Intl.NumberFormat { + const normalized = currency.trim().toUpperCase() || DEFAULT_COST_CURRENCY + const key = `${normalized}:${digits ?? 'default'}` + const cached = currencyFormatters.get(key) + if (cached) return cached + + try { + const options: Intl.NumberFormatOptions = { + style: 'currency', + currency: normalized, + } + if (digits !== undefined) { + options.minimumFractionDigits = digits + options.maximumFractionDigits = digits + } + const formatter = new Intl.NumberFormat('en-US', options) + currencyFormatters.set(key, formatter) + return formatter + } catch { + return currencyFormatter(DEFAULT_COST_CURRENCY, digits) + } +} + +function formatCurrency(value: number, currency: string, digits?: number): string { + return currencyFormatter(currency, digits).format(value) +} + +export function formatCostAxis(value: number, currency: string): string { + if (!Number.isFinite(value) || value <= 0) return formatCurrency(0, currency, 0) + if (value >= 1000) return `${formatCurrency(value / 1000, currency, 0)}k` + if (value >= 1) return formatCurrency(value, currency, 1) + if (value >= 0.01) return formatCurrency(value, currency, 2) + if (value >= 0.0001) return formatCurrency(value, currency, 4) + if (value >= 0.00001) return formatCurrency(value, currency, 5) + return `<${formatCurrency(0.00001, currency, 5)}` } -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 formatCost(value: number, currency: string): string { + if (!Number.isFinite(value) || value <= 0) return formatCurrency(0, currency) + if (value >= 1000) return `${formatCurrency(value / 1000, currency, 1)}k` + if (value >= 1) return formatCurrency(value, currency) + if (value >= 0.01) return formatCurrency(value, currency, 3) + if (value >= 0.0001) return formatCurrency(value, currency, 4) + return formatCostAxis(value, currency) } -export function formatCostPerHour(value: number): string { - return `${formatCost(value)}/hr` +export function formatCostPerHour(value: number, currency: string): string { + return `${formatCost(value, currency)}/hr` } -export function formatHistoricalSpend(pointCount: number, windowTotalCost: number, unavailable: boolean): string { +export function formatHistoricalSpend( + pointCount: number, + windowTotalCost: number, + unavailable: boolean, + currency: string, +): string { if (unavailable || pointCount < 2) return '—' - return windowTotalCost > 0 ? `~${formatCost(windowTotalCost)}` : formatCost(0) + return windowTotalCost > 0 + ? `~${formatCost(windowTotalCost, currency)}` + : formatCost(0, currency) } -export function formatProjectedDailyCost(hourlyCost: number): string { - return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY)}` +export function formatProjectedDailyCost(hourlyCost: number, currency: string): string { + return `~${formatCost(hourlyCost * COST_HOURS_PER_DAY, currency)}` } -export function formatProjectedDailyRate(hourlyCost: number): string { - return `${formatProjectedDailyCost(hourlyCost)}/day` +export function formatProjectedDailyRate(hourlyCost: number, currency: string): string { + return `${formatProjectedDailyCost(hourlyCost, currency)}/day` } -export function formatProjectedMonthlyCost(hourlyCost: number): string { - return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH)}` +export function formatProjectedMonthlyCost(hourlyCost: number, currency: string): string { + return `~${formatCost(hourlyCost * COST_HOURS_PER_MONTH, currency)}` } -export function formatProjectedMonthlyRate(hourlyCost: number): string { - return `${formatProjectedMonthlyCost(hourlyCost)}/mo` +export function formatProjectedMonthlyRate(hourlyCost: number, currency: string): string { + return `${formatProjectedMonthlyCost(hourlyCost, currency)}/mo` } diff --git a/web/src/components/home/CostCard.tsx b/web/src/components/home/CostCard.tsx index c1e05a273..df26efa38 100644 --- a/web/src/components/home/CostCard.tsx +++ b/web/src/components/home/CostCard.tsx @@ -1,7 +1,8 @@ import type { OpenCostSummary } from '../../api/client' import { useOpenCostSummary } from '../../api/client' -import { DollarSign } from 'lucide-react' +import { Coins } from 'lucide-react' import { + DEFAULT_COST_CURRENCY, formatCostPerHour, formatProjectedDailyRate, formatProjectedMonthlyCost, @@ -21,6 +22,7 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) { function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) { const hourlyCost = data.totalHourlyCost ?? 0 + const currency = data.currency ?? DEFAULT_COST_CURRENCY const namespaces = data.namespaces ?? [] const topNamespaces = namespaces.slice(0, 5) @@ -35,7 +37,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
- + Cost Insights {namespaces.length > 0 && ( @@ -50,14 +52,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
- {formatProjectedMonthlyCost(hourlyCost)} + {formatProjectedMonthlyCost(hourlyCost, currency)} /mo
- {formatProjectedDailyRate(hourlyCost)} + {formatProjectedDailyRate(hourlyCost, currency)} · - {formatCostPerHour(hourlyCost)} + {formatCostPerHour(hourlyCost, currency)}
@@ -72,7 +74,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
- {formatProjectedMonthlyRate(ns.hourlyCost)} + {formatProjectedMonthlyRate(ns.hourlyCost, currency)}
) @@ -85,7 +87,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
- {data.currency ?? 'USD'} · projected monthly from {data.window ?? '1h'} window + {currency} · projected monthly from {data.window ?? '1h'} window + {currency !== DEFAULT_COST_CURRENCY && ( + <> · Radar labels OpenCost values as {currency}; no currency conversion + )} OpenCost diff --git a/web/src/components/nav/PrimaryNavRail.tsx b/web/src/components/nav/PrimaryNavRail.tsx index 6b6f58f05..0044eb00b 100644 --- a/web/src/components/nav/PrimaryNavRail.tsx +++ b/web/src/components/nav/PrimaryNavRail.tsx @@ -10,7 +10,7 @@ import { GitBranch, Boxes, Activity, - DollarSign, + Coins, Gauge, ShieldCheck, Settings, @@ -80,7 +80,7 @@ const NAV_ITEMS: NavItemDef[] = [ { view: "gitops", icon: GitBranch, label: "GitOps" }, { view: "checks", icon: ShieldCheck, label: "Checks" }, { view: "capacity", icon: Gauge, label: "Capacity" }, - { view: "cost", icon: DollarSign, label: "Cost" }, + { view: "cost", icon: Coins, label: "Cost" }, ]; interface PrimaryNavRailProps { diff --git a/web/src/components/rightsizing/RightsizingScanView.tsx b/web/src/components/rightsizing/RightsizingScanView.tsx index f9ae5324f..069786f1e 100644 --- a/web/src/components/rightsizing/RightsizingScanView.tsx +++ b/web/src/components/rightsizing/RightsizingScanView.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' -import { AlertTriangle, DollarSign, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react' +import { AlertTriangle, Coins, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react' import { Collapse, CollapseChevron, @@ -194,7 +194,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 865b17548..8f560142a 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -39,6 +39,7 @@ interface Config { timelineDbPath?: string historyLimit?: number prometheusUrl?: string + opencostCurrency?: string argoCdUrl?: string argoCdInsecureTls?: boolean mcp?: boolean | null diff --git a/web/src/components/ui/command-items.ts b/web/src/components/ui/command-items.ts index 4e5542adb..41e94bc55 100644 --- a/web/src/components/ui/command-items.ts +++ b/web/src/components/ui/command-items.ts @@ -8,7 +8,7 @@ import { Activity, Sun, Stethoscope, - DollarSign, + Coins, Gauge, ShieldCheck, GitBranch, @@ -151,7 +151,7 @@ const VIEW_ENTRIES: { { view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" }, { view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" }, { view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" }, - { view: "cost", label: "Cost", icon: DollarSign, shortcut: "g c" }, + { view: "cost", label: "Cost", icon: Coins, shortcut: "g c" }, ]; // The static command-palette items (Views, Resource Kinds, Contexts, From f4b22aaecca478c63417d551adc24449fcf33d10 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Mon, 24 Aug 2026 16:59:43 +0300 Subject: [PATCH 02/18] fix: detect OpenCost currency from cluster config --- README.md | 8 +- cmd/desktop/main.go | 2 +- cmd/explorer/main.go | 2 +- deploy/helm/radar/README.md | 2 +- deploy/helm/radar/values.schema.json | 2 +- deploy/helm/radar/values.yaml | 4 +- docs/configuration.md | 4 +- docs/integrations.md | 4 +- internal/config/opencost.go | 4 +- internal/config/opencost_test.go | 2 +- internal/opencost/currency.go | 210 +++++++++++++++ internal/opencost/currency_test.go | 247 ++++++++++++++++++ internal/opencost/handlers.go | 54 ++-- internal/opencost/handlers_test.go | 8 +- internal/prometheus/client.go | 6 + internal/prometheus/client_test.go | 11 + internal/server/diagnostics.go | 4 +- internal/server/diagnostics_desktop_test.go | 18 ++ internal/server/namespace_scope.go | 3 + internal/server/opencost_application.go | 12 +- internal/server/opencost_workload.go | 17 +- internal/server/opencost_workload_test.go | 2 +- internal/server/server.go | 19 +- internal/server/settings_role_test.go | 31 ++- pkg/opencost/compute.go | 25 +- pkg/opencost/rest_client.go | 6 +- .../components/cost/ApplicationCostTab.tsx | 2 +- web/src/components/cost/CostView.tsx | 9 +- web/src/components/cost/WorkloadCostTab.tsx | 2 +- web/src/components/cost/format.test.ts | 4 +- web/src/components/cost/format.ts | 26 +- web/src/components/home/CostCard.tsx | 2 +- .../components/settings/SettingsDialog.tsx | 86 ++++-- 33 files changed, 714 insertions(+), 124 deletions(-) create mode 100644 internal/opencost/currency.go create mode 100644 internal/opencost/currency_test.go diff --git a/README.md b/README.md index 5650c193d..cf22f163f 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ output) for plain text. URLs, tokens, and suggested commands remain unstyled. | `--prometheus-url` | (auto-discover) | Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) | | `--prometheus-header` | | HTTP header sent with every Prometheus request, format `Key=Value` (repeatable). Required for auth-protected backends. | | `--prometheus-header-from-env` | | HTTP header sent with every Prometheus request, sourced from an environment variable, format `Key=ENV_VAR` (repeatable). | -| `--opencost-currency` | `USD` | ISO 4217 code describing OpenCost values. Radar labels values with this currency but does not convert them. | +| `--opencost-currency` | (auto-detect, then USD) | Override the ISO 4217 currency label for OpenCost values. Radar labels values but does not convert them. | | `--auth-mode` | `none` | Authentication mode: `none`, `proxy`, or `oidc` ([details](docs/authentication.md)) | | `--no-mcp` | `false` | Disable MCP server for AI tool integration | | `--mcp-catalog-stdio` | `false` | Start only the MCP catalog over stdio for registry introspection | @@ -429,9 +429,9 @@ See [docs/capacity.md](docs/capacity.md) for the full reference. ### Cost Insights -Track Kubernetes spending with OpenCost integration. Radar defaults to USD; if OpenCost is -configured with non-USD pricing, pass `--opencost-currency` so Radar labels the values correctly. -Radar does not convert values between currencies. +Track Kubernetes spending with OpenCost integration. Radar reads `currencyCode` from a running +OpenCost pricing configuration when available and otherwise uses USD. Override the label in +Settings → Cost, config, CLI, or Helm. Radar does not convert values between currencies. - Cluster hourly and projected monthly cost, top namespaces by spend - Cost trend charts with 6h/24h/7d range selector diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 5210d09db..2748be0f1 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -66,7 +66,7 @@ func main() { timelineRetention := flag.Duration("timeline-retention", fileCfg.TimelineRetentionOr(7*24*time.Hour), "How long to retain timeline events when --timeline-storage=sqlite (e.g. 168h, 720h). 0 disables age-based cleanup.") timelineMaxSize := flag.String("timeline-max-size", fileCfg.TimelineMaxSizeOr("1Gi"), "Maximum SQLite timeline storage size before pruning oldest events (e.g. 800Mi, 8Gi). 0 disables size-based pruning.") prometheusURL := flag.String("prometheus-url", fileCfg.PrometheusURL, "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)") - openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "ISO 4217 currency code used by OpenCost values (default: USD)") + openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "Override the ISO 4217 currency label for OpenCost values (empty: auto-detect, then USD)") flag.Parse() if *showVersion { diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index 47d2d2f6a..6df07ffad 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -132,7 +132,7 @@ func main() { aiHistory := flag.Bool("ai-history", fileCfg.AIHistoryOr(true), "Persist AI investigations (transcripts + verdicts) to ~/.radar/ai-runs.db so they survive restarts") // Traffic/metrics options prometheusURL := flag.String("prometheus-url", fileCfg.PrometheusURL, "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)") - openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "ISO 4217 currency code used by OpenCost values (default: USD)") + openCostCurrency := flag.String("opencost-currency", fileCfg.OpenCostCurrency, "Override the ISO 4217 currency label for OpenCost values (empty: auto-detect, then USD)") // --prometheus-header Key=Value, repeatable. Defaults populated from // config file; any --prometheus-header flag replaces the file value rather // than merging — matches kubectl semantics (file is the default, CLI wins). diff --git a/deploy/helm/radar/README.md b/deploy/helm/radar/README.md index f7bc7ed0c..d0c807590 100644 --- a/deploy/helm/radar/README.md +++ b/deploy/helm/radar/README.md @@ -172,7 +172,7 @@ lands in the Helm release state. Rotation requires a pod restart. See | `timeline.retention` | SQLite retention (Go duration; `0` disables) | `168h` | | `timeline.maxSize` | SQLite max DB + WAL size before oldest events are pruned (`0` disables) | `800Mi` | | `persistence.enabled` | Enable PVC for SQLite | `false` | -| `cost.currency` | ISO 4217 code describing OpenCost values; Radar labels but does not convert them | `""` (USD) | +| `cost.currency` | Optional ISO 4217 override for OpenCost values; empty auto-detects, then uses USD | `""` | | `traffic.prometheusUrl` | Manual Prometheus/VictoriaMetrics URL (skips auto-discovery) | `""` | | `traffic.prometheusHeaders` | HTTP headers sent with every Prometheus request (auth-protected backends) | `{}` | | `traffic.prometheusHeadersFromEnv` | Prometheus headers sourced from environment variables, for secret-backed auth headers | `{}` | diff --git a/deploy/helm/radar/values.schema.json b/deploy/helm/radar/values.schema.json index 325fad0e1..81795ddca 100644 --- a/deploy/helm/radar/values.schema.json +++ b/deploy/helm/radar/values.schema.json @@ -308,7 +308,7 @@ "currency": { "type": "string", "pattern": "^$|^[A-Za-z]{3}$", - "description": "ISO 4217 code describing OpenCost values; Radar labels but does not convert them." + "description": "Optional ISO 4217 override for OpenCost values; empty auto-detects from a running OpenCost pricing config, then uses USD." } } }, diff --git a/deploy/helm/radar/values.yaml b/deploy/helm/radar/values.yaml index 41d414245..a6cca5ce9 100644 --- a/deploy/helm/radar/values.yaml +++ b/deploy/helm/radar/values.yaml @@ -450,8 +450,8 @@ mcp: # OpenCost display configuration cost: - # ISO 4217 code describing the currency of OpenCost values. Radar labels - # values with this code but does not convert them. Empty defaults to USD. + # Optional ISO 4217 override for OpenCost values. Empty detects currencyCode + # from a running OpenCost pricing config when available, then uses USD. currency: "" # Traffic source configuration diff --git a/docs/configuration.md b/docs/configuration.md index cdc7bbf46..a72e60b2b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,7 +46,7 @@ Persistent defaults for CLI flags. CLI flags always override these values. Manag "timelineMaxSize": "0", "historyLimit": 10000, "prometheusUrl": "", - "opencostCurrency": "USD", + "opencostCurrency": "", "prometheusHeaders": {}, "mcp": true, "debugImage": "" @@ -69,7 +69,7 @@ All fields are optional — omitted fields use built-in defaults. | `timelineMaxSize` | Max SQLite DB + WAL size before pruning oldest events (`0` disables) | | `historyLimit` | Max timeline events to retain | | `prometheusUrl` | Manual Prometheus/VictoriaMetrics URL — skips auto-discovery. Useful when Prometheus is not in the same cluster or uses a non-standard service name. | -| `opencostCurrency` | ISO 4217 code describing the values produced by OpenCost (default `USD`). Radar labels values with this code but does not convert them. Equivalent CLI: `--opencost-currency`. | +| `opencostCurrency` | Optional ISO 4217 override for values produced by OpenCost. Empty detects `currencyCode` from a running OpenCost pricing configuration when Radar auto-discovers cluster Prometheus, then falls back to `USD`. Radar labels values but does not convert them. Equivalent CLI: `--opencost-currency`; an explicit CLI value remains authoritative after restart. | | `prometheusHeaders` | HTTP headers sent with every Prometheus request. Required for auth-protected backends — e.g. `{"X-Scope-OrgID": "my-org"}`. Equivalent CLI: `--prometheus-header Key=Value` (repeatable). Stored in plain text in `config.json` — protect the file accordingly. | | `argoCdUrl` | Manual argocd-server URL for the Argo CD API integration — skips auto-discovery. | | `argoCdToken` | Argo CD API token (get-only account recommended). Stored in plain text — the file is written `0600`; the token is redacted from `GET /api/config`. | diff --git a/docs/integrations.md b/docs/integrations.md index d9f82277e..523936ae0 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1168,7 +1168,7 @@ PolicyReport findings are policy posture, not live operational failure, so they Radar discovers if OpenCost metrics are available in the already-discovered Prometheus. If OpenCost is installed and scraping into Prometheus, cost data appears automatically. The integration is passive and read-only. -OpenCost's Prometheus metrics contain numeric values but no currency metadata. Radar labels them as USD by default. If OpenCost is configured for another currency, set Radar's `--opencost-currency` flag to the matching ISO 4217 code (Helm: `cost.currency`). Radar labels the values but does not convert them. +OpenCost's Prometheus metrics contain numeric values but no currency metadata. When Radar auto-discovers Prometheus in the connected cluster, it looks for `currencyCode` in the pricing ConfigMap referenced by a running OpenCost deployment. If that evidence is unavailable or ambiguous, Radar uses USD. Radar skips cluster inference for a manually configured Prometheus URL because it may serve another cluster. Override the label in Settings → Cost or `opencostCurrency` (CLI: `--opencost-currency`; Helm: `cost.currency`). CLI and Helm overrides remain authoritative after restart. Radar labels the values but does not convert them. ### What Radar Shows @@ -1188,7 +1188,7 @@ OpenCost's Prometheus metrics contain numeric values but no currency metadata. R 1. OpenCost (or Kubecost) deployed in your cluster, with its metrics being scraped by Prometheus -OpenCost cost data is not CRD-based — no custom resources are required. Cost views appear automatically when metrics are detected; they are hidden when no OpenCost metrics are found in Prometheus. Non-USD values require the currency label configuration described above. +OpenCost cost data is not CRD-based — no custom resources are required. Cost views appear automatically when metrics are detected; they are hidden when no OpenCost metrics are found in Prometheus. --- diff --git a/internal/config/opencost.go b/internal/config/opencost.go index 8771e51f4..3a93588fd 100644 --- a/internal/config/opencost.go +++ b/internal/config/opencost.go @@ -7,12 +7,10 @@ import ( "golang.org/x/text/currency" ) -const defaultOpenCostCurrency = "USD" - func NormalizeOpenCostCurrency(raw string) (string, error) { code := strings.ToUpper(strings.TrimSpace(raw)) if code == "" { - return defaultOpenCostCurrency, nil + return "", nil } if code == "XXX" || code == "XTS" { return "", fmt.Errorf("must be a monetary ISO 4217 currency code") diff --git a/internal/config/opencost_test.go b/internal/config/opencost_test.go index bd0d6bd97..774fffe8d 100644 --- a/internal/config/opencost_test.go +++ b/internal/config/opencost_test.go @@ -9,7 +9,7 @@ func TestNormalizeOpenCostCurrency(t *testing.T) { want string wantErr bool }{ - {name: "default", want: "USD"}, + {name: "auto", want: ""}, {name: "trim and uppercase", raw: " gbp ", want: "GBP"}, {name: "zero decimal currency", raw: "jpy", want: "JPY"}, {name: "unknown", raw: "ZZZ", wantErr: true}, diff --git a/internal/opencost/currency.go b/internal/opencost/currency.go new file mode 100644 index 000000000..457247239 --- /dev/null +++ b/internal/opencost/currency.go @@ -0,0 +1,210 @@ +package opencost + +import ( + "encoding/json" + "strings" + "sync" + "time" + + "github.com/skyhook-io/radar/internal/config" + "github.com/skyhook-io/radar/internal/k8s" + prometheuspkg "github.com/skyhook-io/radar/internal/prometheus" + pkgopencost "github.com/skyhook-io/radar/pkg/opencost" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + appslisters "k8s.io/client-go/listers/apps/v1" + corelisters "k8s.io/client-go/listers/core/v1" +) + +const currencyDetectionTTL = 30 * time.Second + +type CurrencyResolver struct { + mu sync.Mutex + override string + cached string + cachedDetected bool + expiresAt time.Time +} + +func NewCurrencyResolver(override string) *CurrencyResolver { + return &CurrencyResolver{override: override} +} + +func (r *CurrencyResolver) Resolve() string { + var cache currencyCache + if resourceCache := k8s.GetResourceCache(); resourceCache != nil { + cache = resourceCache + } + return r.resolve(cache, clusterCurrencyDetectionAllowed(), time.Now()) +} + +func (r *CurrencyResolver) resolve(cache currencyCache, detectionAllowed bool, now time.Time) string { + r.mu.Lock() + defer r.mu.Unlock() + + if r.override != "" { + return r.override + } + if !detectionAllowed { + return pkgopencost.DefaultCurrency + } + if now.Before(r.expiresAt) { + return r.cached + } + + detection := detectOpenCostCurrencyState(cache) + if detection.currency != "" { + r.cached = detection.currency + r.cachedDetected = true + } else if detection.hasActiveDeployment || !r.cachedDetected { + r.cached = pkgopencost.DefaultCurrency + r.cachedDetected = false + } + r.expiresAt = now.Add(currencyDetectionTTL) + return r.cached +} + +func (r *CurrencyResolver) SetOverride(override string) { + r.mu.Lock() + r.override = override + r.cached = "" + r.cachedDetected = false + r.expiresAt = time.Time{} + r.mu.Unlock() +} + +func (r *CurrencyResolver) Invalidate() { + r.mu.Lock() + r.cached = "" + r.cachedDetected = false + r.expiresAt = time.Time{} + r.mu.Unlock() +} + +func clusterCurrencyDetectionAllowed() bool { + client := prometheuspkg.GetClient() + return client == nil || !client.HasManualURL() +} + +type currencyCache interface { + Deployments() appslisters.DeploymentLister + ConfigMaps() corelisters.ConfigMapLister +} + +func detectOpenCostCurrency(cache currencyCache) string { + return detectOpenCostCurrencyState(cache).currency +} + +type currencyDetection struct { + currency string + hasActiveDeployment bool +} + +func detectOpenCostCurrencyState(cache currencyCache) currencyDetection { + if cache == nil || cache.Deployments() == nil || cache.ConfigMaps() == nil { + return currencyDetection{} + } + deployments, err := cache.Deployments().List(labels.Everything()) + if err != nil { + return currencyDetection{} + } + + detection := currencyDetection{} + for _, deployment := range deployments { + if deployment.Status.AvailableReplicas == 0 || + (deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0) { + continue + } + if !isOpenCostDeployment(deployment.Name, deployment.Labels, deployment.Spec.Template.Spec.Containers) { + continue + } + detection.hasActiveDeployment = true + configMapNames := map[string]bool{} + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, env := range container.Env { + if env.Name == "PRICING_CONFIGMAP_NAME" && strings.TrimSpace(env.Value) != "" { + configMapNames[strings.TrimSpace(env.Value)] = true + } + } + } + if len(configMapNames) == 0 { + configMapNames["custom-pricing-model"] = true + configMapNames["pricing-configs"] = true + } + for name := range configMapNames { + configMap, getErr := cache.ConfigMaps().ConfigMaps(deployment.Namespace).Get(name) + if getErr != nil { + continue + } + code := currencyFromConfigMap(configMap.Data) + if code == "" { + continue + } + if detection.currency != "" && detection.currency != code { + detection.currency = "" + return detection + } + detection.currency = code + } + } + return detection +} + +func isOpenCostDeployment(name string, objectLabels map[string]string, containers []corev1.Container) bool { + identities := []string{name} + for _, key := range []string{"app", "name", "component", "app.kubernetes.io/name", "app.kubernetes.io/instance", "app.kubernetes.io/component"} { + identities = append(identities, objectLabels[key]) + } + for _, container := range containers { + identities = append(identities, container.Name, container.Image) + } + for _, identity := range identities { + identity = strings.ToLower(identity) + if strings.Contains(identity, "opencost") || strings.Contains(identity, "kubecost") || + strings.Contains(identity, "cost-model") || strings.Contains(identity, "cost-analyzer") { + return true + } + } + return false +} + +func currencyFromConfigMap(data map[string]string) string { + detected := "" + consider := func(value string) bool { + code := normalizedDetectedCurrency(value) + if code == "" { + return true + } + if detected != "" && detected != code { + return false + } + detected = code + return true + } + for key, value := range data { + if strings.EqualFold(key, "currencyCode") { + if !consider(value) { + return "" + } + } + } + for _, value := range data { + var pricing struct { + CurrencyCode string `json:"currencyCode"` + } + if json.Unmarshal([]byte(value), &pricing) == nil && pricing.CurrencyCode != "" { + if !consider(pricing.CurrencyCode) { + return "" + } + } + } + return detected +} + +func normalizedDetectedCurrency(value string) string { + code, err := config.NormalizeOpenCostCurrency(value) + if err != nil { + return "" + } + return code +} diff --git a/internal/opencost/currency_test.go b/internal/opencost/currency_test.go new file mode 100644 index 000000000..e79964dd0 --- /dev/null +++ b/internal/opencost/currency_test.go @@ -0,0 +1,247 @@ +package opencost + +import ( + "testing" + "time" + + prometheuspkg "github.com/skyhook-io/radar/internal/prometheus" + pkgopencost "github.com/skyhook-io/radar/pkg/opencost" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + appslisters "k8s.io/client-go/listers/apps/v1" + corelisters "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" +) + +type testCurrencyCache struct { + deployments appslisters.DeploymentLister + configMaps corelisters.ConfigMapLister +} + +func (c testCurrencyCache) Deployments() appslisters.DeploymentLister { return c.deployments } +func (c testCurrencyCache) ConfigMaps() corelisters.ConfigMapLister { return c.configMaps } + +func newTestCurrencyCache(t *testing.T, objects ...any) testCurrencyCache { + t.Helper() + deploymentIndexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + configMapIndexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + for _, object := range objects { + var err error + switch value := object.(type) { + case *appsv1.Deployment: + err = deploymentIndexer.Add(value) + case *corev1.ConfigMap: + err = configMapIndexer.Add(value) + default: + t.Fatalf("unsupported object %T", object) + } + if err != nil { + t.Fatal(err) + } + } + return testCurrencyCache{ + deployments: appslisters.NewDeploymentLister(deploymentIndexer), + configMaps: corelisters.NewConfigMapLister(configMapIndexer), + } +} + +func openCostDeployment(namespace, name, configMapName string) *appsv1.Deployment { + env := []corev1.EnvVar{} + if configMapName != "" { + env = append(env, corev1.EnvVar{Name: "PRICING_CONFIGMAP_NAME", Value: configMapName}) + } + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{ + {Name: "cost-model", Image: "ghcr.io/opencost/opencost:latest", Env: env}, + }}}}, + Status: appsv1.DeploymentStatus{AvailableReplicas: 1}, + } +} + +func pricingConfigMap(namespace, name string, data map[string]string) *corev1.ConfigMap { + return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Data: data} +} + +func TestDetectOpenCostCurrency(t *testing.T) { + tests := []struct { + name string + objects []any + want string + }{ + {name: "no cache", want: ""}, + { + name: "official chart config map", + objects: []any{ + openCostDeployment("opencost", "opencost", "custom-pricing-model"), + pricingConfigMap("opencost", "custom-pricing-model", map[string]string{"currencyCode": " eur ", "CPU": "0.03"}), + }, + want: "EUR", + }, + { + name: "custom referenced config map with json", + objects: []any{ + openCostDeployment("finops", "cost-analyzer", "company-pricing"), + pricingConfigMap("finops", "company-pricing", map[string]string{"default.json": `{"currencyCode":"gbp","CPU":"0.03"}`}), + }, + want: "GBP", + }, + { + name: "referenced config map takes precedence over stale default", + objects: []any{ + openCostDeployment("finops", "opencost", "company-pricing"), + pricingConfigMap("finops", "company-pricing", map[string]string{"currencyCode": "GBP"}), + pricingConfigMap("finops", "custom-pricing-model", map[string]string{"currencyCode": "USD"}), + }, + want: "GBP", + }, + { + name: "unrelated config map ignored", + objects: []any{ + pricingConfigMap("app", "custom-pricing-model", map[string]string{"currencyCode": "JPY"}), + }, + want: "", + }, + { + name: "invalid currency ignored", + objects: []any{ + openCostDeployment("opencost", "opencost", "custom-pricing-model"), + pricingConfigMap("opencost", "custom-pricing-model", map[string]string{"currencyCode": "EURO"}), + }, + want: "", + }, + { + name: "conflicting active installations are ambiguous", + objects: []any{ + openCostDeployment("one", "opencost", "custom-pricing-model"), + pricingConfigMap("one", "custom-pricing-model", map[string]string{"currencyCode": "EUR"}), + openCostDeployment("two", "kubecost", "pricing-configs"), + pricingConfigMap("two", "pricing-configs", map[string]string{"currencyCode": "JPY"}), + }, + want: "", + }, + { + name: "inactive installation is ignored", + objects: []any{ + func() *appsv1.Deployment { + deployment := openCostDeployment("old", "kubecost", "pricing-configs") + deployment.Status.AvailableReplicas = 0 + return deployment + }(), + pricingConfigMap("old", "pricing-configs", map[string]string{"currencyCode": "JPY"}), + openCostDeployment("live", "opencost", "custom-pricing-model"), + pricingConfigMap("live", "custom-pricing-model", map[string]string{"currencyCode": "EUR"}), + }, + want: "EUR", + }, + { + name: "conflicting values within one config map are ambiguous", + objects: []any{ + openCostDeployment("opencost", "opencost", "custom-pricing-model"), + pricingConfigMap("opencost", "custom-pricing-model", map[string]string{ + "aws.json": `{"currencyCode":"USD"}`, + "gcp.json": `{"currencyCode":"EUR"}`, + }), + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.objects == nil { + if got := detectOpenCostCurrency(nil); got != tt.want { + t.Fatalf("detectOpenCostCurrency(nil) = %q, want %q", got, tt.want) + } + return + } + if got := detectOpenCostCurrency(newTestCurrencyCache(t, tt.objects...)); got != tt.want { + t.Errorf("detectOpenCostCurrency() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCurrencyResolverOverride(t *testing.T) { + resolver := NewCurrencyResolver("GBP") + if got := resolver.Resolve(); got != "GBP" { + t.Fatalf("Resolve() = %q, want GBP", got) + } + resolver.SetOverride("JPY") + if got := resolver.Resolve(); got != "JPY" { + t.Fatalf("Resolve() after SetOverride = %q, want JPY", got) + } + resolver.SetOverride("") + if got := resolver.Resolve(); got != pkgopencost.DefaultCurrency { + t.Fatalf("Resolve() after clearing override = %q, want %s", got, pkgopencost.DefaultCurrency) + } +} + +func TestCurrencyResolverRetainsDetectedCurrencyWhileDeploymentIsUnavailable(t *testing.T) { + now := time.Now() + resolver := NewCurrencyResolver("") + active := newTestCurrencyCache(t, + openCostDeployment("opencost", "opencost", "custom-pricing-model"), + pricingConfigMap("opencost", "custom-pricing-model", map[string]string{"currencyCode": "EUR"}), + ) + if got := resolver.resolve(active, true, now); got != "EUR" { + t.Fatalf("resolve(active) = %q, want EUR", got) + } + + inactiveDeployment := openCostDeployment("opencost", "opencost", "custom-pricing-model") + inactiveDeployment.Status.AvailableReplicas = 0 + inactive := newTestCurrencyCache(t, + inactiveDeployment, + pricingConfigMap("opencost", "custom-pricing-model", map[string]string{"currencyCode": "EUR"}), + ) + if got := resolver.resolve(inactive, true, now.Add(currencyDetectionTTL)); got != "EUR" { + t.Fatalf("resolve(inactive) = %q, want last detected EUR", got) + } + + resolver.Invalidate() + if got := resolver.resolve(inactive, true, now.Add(2*currencyDetectionTTL)); got != pkgopencost.DefaultCurrency { + t.Fatalf("resolve(inactive) after invalidation = %q, want %s", got, pkgopencost.DefaultCurrency) + } +} + +func TestCurrencyResolverDoesNotRetainDetectionWhenActiveConfigBecomesAmbiguous(t *testing.T) { + now := time.Now() + resolver := NewCurrencyResolver("") + active := newTestCurrencyCache(t, + openCostDeployment("one", "opencost", "custom-pricing-model"), + pricingConfigMap("one", "custom-pricing-model", map[string]string{"currencyCode": "EUR"}), + ) + if got := resolver.resolve(active, true, now); got != "EUR" { + t.Fatalf("resolve(active) = %q, want EUR", got) + } + + ambiguous := newTestCurrencyCache(t, + openCostDeployment("one", "opencost", "custom-pricing-model"), + pricingConfigMap("one", "custom-pricing-model", map[string]string{"currencyCode": "EUR"}), + openCostDeployment("two", "kubecost", "pricing-configs"), + pricingConfigMap("two", "pricing-configs", map[string]string{"currencyCode": "JPY"}), + ) + if got := resolver.resolve(ambiguous, true, now.Add(currencyDetectionTTL)); got != pkgopencost.DefaultCurrency { + t.Fatalf("resolve(ambiguous) = %q, want %s", got, pkgopencost.DefaultCurrency) + } +} + +func TestClusterCurrencyDetectionAllowed(t *testing.T) { + prometheuspkg.Initialize(nil, nil, "") + t.Cleanup(func() { + prometheuspkg.SetManualURL("") + }) + + if !clusterCurrencyDetectionAllowed() { + t.Fatal("cluster currency detection disabled without a manual Prometheus URL") + } + prometheuspkg.SetManualURL("https://prometheus.example.com") + if clusterCurrencyDetectionAllowed() { + t.Fatal("cluster currency detection enabled with a manual Prometheus URL") + } + prometheuspkg.SetManualURL("") + if !clusterCurrencyDetectionAllowed() { + t.Fatal("cluster currency detection did not resume after clearing the manual Prometheus URL") + } +} diff --git a/internal/opencost/handlers.go b/internal/opencost/handlers.go index 74f395ca3..3d2bf6fc6 100644 --- a/internal/opencost/handlers.go +++ b/internal/opencost/handlers.go @@ -17,28 +17,26 @@ import ( ) // RegisterRoutes registers OpenCost routes on the given router. -func RegisterRoutes(r chi.Router, currency string) { - if currency == "" { - currency = pkgopencost.DefaultCurrency - } - r.Get("/opencost/summary", func(w http.ResponseWriter, r *http.Request) { handleSummary(w, r, currency) }) - r.Get("/opencost/workloads", func(w http.ResponseWriter, r *http.Request) { handleWorkloads(w, r, currency) }) - r.Get("/opencost/trend", func(w http.ResponseWriter, r *http.Request) { handleTrend(w, r, currency) }) - r.Get("/opencost/nodes", func(w http.ResponseWriter, r *http.Request) { handleNodes(w, r, currency) }) +func RegisterRoutes(r chi.Router, resolveCurrency func() string) { + r.Get("/opencost/summary", func(w http.ResponseWriter, r *http.Request) { handleSummary(w, r, resolveCurrency) }) + r.Get("/opencost/workloads", func(w http.ResponseWriter, r *http.Request) { handleWorkloads(w, r, resolveCurrency) }) + r.Get("/opencost/trend", func(w http.ResponseWriter, r *http.Request) { handleTrend(w, r, resolveCurrency) }) + r.Get("/opencost/nodes", func(w http.ResponseWriter, r *http.Request) { handleNodes(w, r, resolveCurrency) }) } // handleSummary returns namespace-level cost summary from OpenCost Prometheus metrics. -func handleSummary(w http.ResponseWriter, r *http.Request, currency string) { +func handleSummary(w http.ResponseWriter, r *http.Request, resolveCurrency func() string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: resolvedCurrency(resolveCurrency)}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (summary): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: ConnectionFailureReason(err), Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) return } + currency := resolvedCurrency(resolveCurrency) resp := pkgopencost.ComputeCostSummaryFromProm( r.Context(), client.Prom(), pkgopencost.SummaryOptions{Currency: currency}) writeJSON(w, http.StatusOK, resp) @@ -53,7 +51,7 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) { } // handleWorkloads returns workload-level cost breakdown for a namespace. -func handleWorkloads(w http.ResponseWriter, r *http.Request, currency string) { +func handleWorkloads(w http.ResponseWriter, r *http.Request, resolveCurrency func() string) { ns := r.URL.Query().Get("namespace") if ns == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "namespace parameter is required"}) @@ -62,17 +60,17 @@ func handleWorkloads(w http.ResponseWriter, r *http.Request, currency string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: pkgopencost.ReasonNoPrometheus, Currency: resolvedCurrency(resolveCurrency)}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (workloads): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: ConnectionFailureReason(err), Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) return } resp := pkgopencost.ComputeWorkloadsFromProm(r.Context(), client.Prom(), ns, BuildPodOwnerLookup(ns)) - resp.Currency = currency + resp.Currency = resolvedCurrency(resolveCurrency) writeJSON(w, http.StatusOK, resp) } @@ -122,40 +120,50 @@ func stripReplicaSetSuffix(name string) string { } // handleTrend returns cost trend data over time as a stacked series per namespace. -func handleTrend(w http.ResponseWriter, r *http.Request, currency string) { +func handleTrend(w http.ResponseWriter, r *http.Request, resolveCurrency func() string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: resolvedCurrency(resolveCurrency)}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (trend): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) return } resp := pkgopencost.ComputeCostTrendFromProm(r.Context(), client.Prom(), pkgopencost.TrendPromOptions{Range: r.URL.Query().Get("range")}) - resp.Currency = currency + resp.Currency = resolvedCurrency(resolveCurrency) writeJSON(w, http.StatusOK, resp) } // handleNodes returns per-node cost breakdown. -func handleNodes(w http.ResponseWriter, r *http.Request, currency string) { +func handleNodes(w http.ResponseWriter, r *http.Request, resolveCurrency func() string) { client := prometheuspkg.GetClient() if client == nil { - writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: pkgopencost.ReasonNoPrometheus, Currency: resolvedCurrency(resolveCurrency)}) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Printf("[opencost] EnsureConnected failed (nodes): %v", err) - writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: currency}) + writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) return } resp := pkgopencost.ComputeNodeCosts(r.Context(), client.Prom()) - resp.Currency = currency + resp.Currency = resolvedCurrency(resolveCurrency) attachNodeProviderIDs(resp) writeJSON(w, http.StatusOK, resp) } +func resolvedCurrency(resolve func() string) string { + if resolve == nil { + return pkgopencost.DefaultCurrency + } + if currency := resolve(); currency != "" { + return currency + } + return pkgopencost.DefaultCurrency +} + func ConnectionFailureReason(err error) string { if errors.Is(err, prometheuspkg.ErrPrometheusNotFound) { return pkgopencost.ReasonNoPrometheus diff --git a/internal/opencost/handlers_test.go b/internal/opencost/handlers_test.go index dc124aebc..986d2ca55 100644 --- a/internal/opencost/handlers_test.go +++ b/internal/opencost/handlers_test.go @@ -13,7 +13,7 @@ func TestUnavailableResponsesIncludeCurrency(t *testing.T) { tests := []struct { name string target string - handler func(http.ResponseWriter, *http.Request, string) + handler func(http.ResponseWriter, *http.Request, func() string) }{ {name: "summary", target: "/opencost/summary", handler: handleSummary}, {name: "workloads", target: "/opencost/workloads?namespace=default", handler: handleWorkloads}, @@ -24,7 +24,7 @@ func TestUnavailableResponsesIncludeCurrency(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tt.target, nil) w := httptest.NewRecorder() - tt.handler(w, req, "GBP") + tt.handler(w, req, func() string { return "GBP" }) if w.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) @@ -66,7 +66,7 @@ func TestConnectedResponsesIncludeCurrency(t *testing.T) { tests := []struct { name string target string - handler func(http.ResponseWriter, *http.Request, string) + handler func(http.ResponseWriter, *http.Request, func() string) }{ {name: "summary", target: "/opencost/summary", handler: handleSummary}, {name: "workloads", target: "/opencost/workloads?namespace=default", handler: handleWorkloads}, @@ -76,7 +76,7 @@ func TestConnectedResponsesIncludeCurrency(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() - tt.handler(w, httptest.NewRequest(http.MethodGet, tt.target, nil), "GBP") + tt.handler(w, httptest.NewRequest(http.MethodGet, tt.target, nil), func() string { return "GBP" }) if w.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) diff --git a/internal/prometheus/client.go b/internal/prometheus/client.go index aa1b76653..1e884317c 100644 --- a/internal/prometheus/client.go +++ b/internal/prometheus/client.go @@ -290,6 +290,12 @@ func (c *Client) GetStatus() prom.Status { } } +func (c *Client) HasManualURL() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.manualURL != "" +} + // EnsureConnected attempts to discover and connect to Prometheus if not // already connected. Returns the base URL and base path, or an error. func (c *Client) EnsureConnected(ctx context.Context) (string, string, error) { diff --git a/internal/prometheus/client_test.go b/internal/prometheus/client_test.go index 1eeb32c82..92bc59891 100644 --- a/internal/prometheus/client_test.go +++ b/internal/prometheus/client_test.go @@ -12,6 +12,17 @@ import ( "github.com/skyhook-io/radar/internal/errorlog" ) +func TestHasManualURL(t *testing.T) { + client := &Client{} + if client.HasManualURL() { + t.Fatal("HasManualURL() = true without a configured URL") + } + client.manualURL = "https://prometheus.example.com" + if !client.HasManualURL() { + t.Fatal("HasManualURL() = false with a configured URL") + } +} + func TestProbe(t *testing.T) { tests := []struct { name string diff --git a/internal/server/diagnostics.go b/internal/server/diagnostics.go index 2dca0d6c3..040781d14 100644 --- a/internal/server/diagnostics.go +++ b/internal/server/diagnostics.go @@ -524,7 +524,9 @@ func (s *Server) handleDiagnostics(w http.ResponseWriter, r *http.Request) { // Config collectSafe("config", &errs, func() { if s.diagConfig != nil { - snap.Config = s.diagConfig + current := *s.diagConfig + current.OpenCostCurrency = s.resolvedOpenCostCurrency() + snap.Config = ¤t } }) diff --git a/internal/server/diagnostics_desktop_test.go b/internal/server/diagnostics_desktop_test.go index e13aa74dd..9f17d0b87 100644 --- a/internal/server/diagnostics_desktop_test.go +++ b/internal/server/diagnostics_desktop_test.go @@ -7,9 +7,27 @@ import ( "testing" "github.com/skyhook-io/radar/internal/desktopenv" + internalopencost "github.com/skyhook-io/radar/internal/opencost" "github.com/skyhook-io/radar/internal/version" ) +func TestDiagnosticsReportsRunningOpenCostCurrency(t *testing.T) { + rec := httptest.NewRecorder() + s := &Server{ + diagConfig: &DiagConfig{OpenCostCurrency: "USD"}, + openCostCurrency: internalopencost.NewCurrencyResolver("GBP"), + } + s.handleDiagnostics(rec, httptest.NewRequest(http.MethodGet, "/api/diagnostics", nil)) + + var snapshot DiagnosticsSnapshot + if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.Config == nil || snapshot.Config.OpenCostCurrency != "GBP" { + t.Fatalf("diagnostics currency = %#v, want GBP", snapshot.Config) + } +} + // The CLI and the desktop app share this endpoint. A CLI snapshot must not // carry a Desktop section at all — an empty one in a bug report reads as // "we looked and the host reported nothing", which is a different claim. diff --git a/internal/server/namespace_scope.go b/internal/server/namespace_scope.go index ba47b24f5..99d8f9df8 100644 --- a/internal/server/namespace_scope.go +++ b/internal/server/namespace_scope.go @@ -168,6 +168,9 @@ func (s *Server) invalidatePostContextSwitchCaches() { if s.capacityIssueMemo != nil { s.capacityIssueMemo.clear() } + if s.openCostCurrency != nil { + s.openCostCurrency.Invalidate() + } k8s.InvalidateUserCapabilitiesCache() clearPackagesCache() clearApplicationsCache() diff --git a/internal/server/opencost_application.go b/internal/server/opencost_application.go index 70c0f9881..6243d1e40 100644 --- a/internal/server/opencost_application.go +++ b/internal/server/opencost_application.go @@ -32,14 +32,14 @@ func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Reques client := prometheuspkg.GetClient() if client == nil { resp := pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, pkgopencost.ReasonNoPrometheus) - resp.Currency = s.openCostCurrency + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } if _, _, err := client.EnsureConnected(r.Context()); err != nil { log.Print("[opencost] EnsureConnected failed for application cost") resp := pkgopencost.UnavailableApplicationCostResponse(inputs, unavailable, unsupported, internalopencost.ConnectionFailureReason(err)) - resp.Currency = s.openCostCurrency + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -51,7 +51,7 @@ func (s *Server) handleOpenCostApplication(w http.ResponseWriter, r *http.Reques } resp := pkgopencost.BuildApplicationCostResponse(inputs, unavailable, unsupported, namespaceCosts) - resp.Currency = s.openCostCurrency + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) } @@ -77,7 +77,7 @@ func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.R Workloads: refs, Unavailable: unavailable, }) - resp.Currency = s.openCostCurrency + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -89,7 +89,7 @@ func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.R Unavailable: unavailable, UnavailableReason: internalopencost.ConnectionFailureReason(err), }) - resp.Currency = s.openCostCurrency + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -99,7 +99,7 @@ func (s *Server) handleOpenCostApplicationTrend(w http.ResponseWriter, r *http.R Workloads: refs, Unavailable: unavailable, }) - resp.Currency = s.openCostCurrency + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) } diff --git a/internal/server/opencost_workload.go b/internal/server/opencost_workload.go index d8d4af91e..4e715eb87 100644 --- a/internal/server/opencost_workload.go +++ b/internal/server/opencost_workload.go @@ -39,13 +39,13 @@ func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) Namespace: namespace, Kind: kind, Name: name, - Currency: s.openCostCurrency, } client := prometheuspkg.GetClient() if client == nil { resp.Available = false resp.Reason = pkgopencost.ReasonNoPrometheus + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -53,13 +53,14 @@ func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) log.Print("[opencost] EnsureConnected failed for workload cost") resp.Available = false resp.Reason = internalopencost.ConnectionFailureReason(err) + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } workloads := pkgopencost.ComputeWorkloadsFromProm(r.Context(), client.Prom(), namespace, internalopencost.BuildPodOwnerLookup(namespace)) result := focusOpenCostWorkload(workloads, kind, namespace, name, desiredReplicas) - result.Currency = s.openCostCurrency + result.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, result) } @@ -81,13 +82,13 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ Kind: kind, Name: name, Range: r.URL.Query().Get("range"), - Currency: s.openCostCurrency, } client := prometheuspkg.GetClient() if client == nil { resp.Available = false resp.Reason = pkgopencost.ReasonNoPrometheus + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -95,6 +96,7 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ log.Print("[opencost] EnsureConnected failed for workload trend") resp.Available = false resp.Reason = internalopencost.ConnectionFailureReason(err) + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -105,10 +107,17 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ Kind: kind, Name: name, }) - result.Currency = s.openCostCurrency + result.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, result) } +func (s *Server) resolvedOpenCostCurrency() string { + if s.openCostCurrency == nil { + return pkgopencost.DefaultCurrency + } + return s.openCostCurrency.Resolve() +} + func (s *Server) parseOpenCostWorkloadRequest(w http.ResponseWriter, r *http.Request) (kind, namespace, name string, ok bool) { kind, supported := pkgopencost.CanonicalWorkloadKind(chi.URLParam(r, "kind")) if !supported { diff --git a/internal/server/opencost_workload_test.go b/internal/server/opencost_workload_test.go index c98cded43..aeaaec325 100644 --- a/internal/server/opencost_workload_test.go +++ b/internal/server/opencost_workload_test.go @@ -17,7 +17,7 @@ import ( ) func TestOpenCostDetailResponsesIncludeCurrencyWhenUnavailable(t *testing.T) { - s := &Server{openCostCurrency: "GBP"} + s := &Server{openCostCurrency: internalopencost.NewCurrencyResolver("GBP")} tests := []struct { name string method string diff --git a/internal/server/server.go b/internal/server/server.go index 03661c588..9cfe11f31 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -58,7 +58,6 @@ import ( "github.com/skyhook-io/radar/pkg/conditions" "github.com/skyhook-io/radar/pkg/hpadiag" "github.com/skyhook-io/radar/pkg/k8score" - pkgopencost "github.com/skyhook-io/radar/pkg/opencost" "github.com/skyhook-io/radar/pkg/perfstats" "github.com/skyhook-io/radar/pkg/rbac" topology "github.com/skyhook-io/radar/pkg/topology" @@ -83,7 +82,7 @@ type Server struct { mcpReadOnlyHandler http.Handler diagConfig *DiagConfig effectiveConfig *config.Config // running config for GET /api/config - openCostCurrency string + openCostCurrency *opencost.CurrencyResolver authConfig auth.Config permCache *auth.PermissionCache oidcHandler *auth.OIDCHandler @@ -177,9 +176,6 @@ type Config struct { // New creates a new server instance func New(cfg Config) *Server { cfg.AuthConfig.Defaults() - if cfg.OpenCostCurrency == "" { - cfg.OpenCostCurrency = pkgopencost.DefaultCurrency - } basePath, err := NormalizeBasePath(cfg.BasePath) if err != nil { log.Fatalf("Invalid base path %q: %v", cfg.BasePath, err) @@ -204,7 +200,7 @@ func New(cfg Config) *Server { mcpReadOnlyHandler: cfg.MCPReadOnlyHandler, diagConfig: cfg.DiagConfig, effectiveConfig: cfg.EffectiveConfig, - openCostCurrency: cfg.OpenCostCurrency, + openCostCurrency: opencost.NewCurrencyResolver(cfg.OpenCostCurrency), authConfig: cfg.AuthConfig, cloudConnectCfg: cfg.CloudConnect, topoMemo: topology.NewMemoizer(5 * time.Second), @@ -713,7 +709,7 @@ func (s *Server) setupAppRoutes(r chi.Router) { r.Post("/opencost/application/trend", s.handleOpenCostApplicationTrend) r.Get("/opencost/workload/{kind}/{namespace}/{name}", s.handleOpenCostWorkload) r.Get("/opencost/workload/{kind}/{namespace}/{name}/trend", s.handleOpenCostWorkloadTrend) - opencost.RegisterRoutes(r, s.openCostCurrency) + opencost.RegisterRoutes(r, s.resolvedOpenCostCurrency) // FluxCD routes r.Post("/flux/{kind}/{namespace}/{name}/reconcile", s.handleFluxReconcile) @@ -5178,7 +5174,8 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { s.writeJSON(w, resp) } -// handlePutConfig replaces the entire config file. Changes take effect on next restart. +// handlePutConfig replaces the entire config file. Most changes take effect on next restart; +// the OpenCost currency override is also applied to the running server. // Unlike handlePutSettings (which merges fields), this is a full replacement. // PrometheusHeaders and the Argo CD token are preserved from the on-disk file: the GET // response redacts them, so a UI round-trip would otherwise silently wipe the user's @@ -5230,6 +5227,9 @@ func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) { s.writeError(w, http.StatusInternalServerError, err.Error()) return } + if s.openCostCurrency != nil { + s.openCostCurrency.SetOverride(result.OpenCostCurrency) + } result.PrometheusHeaders = nil result.ArgoCDToken = "" s.writeJSON(w, result) @@ -5305,6 +5305,9 @@ func (s *Server) handleApplyPrometheusURL(w http.ResponseWriter, r *http.Request traffic.SetMetricsHeaders(headers) } prometheuspkg.Reset() + if s.openCostCurrency != nil { + s.openCostCurrency.Invalidate() + } resp := struct { Connected bool `json:"connected"` diff --git a/internal/server/settings_role_test.go b/internal/server/settings_role_test.go index 063d7c299..46bad1d99 100644 --- a/internal/server/settings_role_test.go +++ b/internal/server/settings_role_test.go @@ -9,6 +9,7 @@ import ( "github.com/skyhook-io/radar/internal/auth" "github.com/skyhook-io/radar/internal/config" + internalopencost "github.com/skyhook-io/radar/internal/opencost" ) // userWithGroups builds an authenticated user carrying the given groups, used @@ -17,10 +18,10 @@ func userWithGroups(groups ...string) *auth.User { return &auth.User{Username: "u@example.com", Groups: groups} } -func TestPutConfigPersistsHiddenOpenCostCurrency(t *testing.T) { +func TestPutConfigPersistsAndAppliesOpenCostCurrency(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("USERPROFILE", t.TempDir()) - s := &Server{} + s := &Server{openCostCurrency: internalopencost.NewCurrencyResolver("JPY")} r := httptest.NewRequest(http.MethodPut, "/api/config", strings.NewReader(`{"port":9280,"opencostCurrency":" gbp "}`)) w := httptest.NewRecorder() @@ -32,6 +33,32 @@ func TestPutConfigPersistsHiddenOpenCostCurrency(t *testing.T) { if got := config.Load().OpenCostCurrency; got != "GBP" { t.Errorf("opencostCurrency = %q, want GBP", got) } + if got := s.openCostCurrency.Resolve(); got != "GBP" { + t.Errorf("running currency = %q, want GBP", got) + } +} + +func TestPutConfigPreservesEmptyOpenCostCurrencyAsAuto(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + if err := config.Save(config.Config{OpenCostCurrency: "GBP"}); err != nil { + t.Fatal(err) + } + s := &Server{openCostCurrency: internalopencost.NewCurrencyResolver("GBP")} + r := httptest.NewRequest(http.MethodPut, "/api/config", strings.NewReader(`{"opencostCurrency":""}`)) + w := httptest.NewRecorder() + + s.handlePutConfig(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if got := config.Load().OpenCostCurrency; got != "" { + t.Errorf("opencostCurrency = %q, want auto", got) + } + if got := s.openCostCurrency.Resolve(); got != "USD" { + t.Errorf("running currency = %q, want auto fallback USD", got) + } } func TestPutConfigRejectsInvalidOpenCostCurrency(t *testing.T) { diff --git a/pkg/opencost/compute.go b/pkg/opencost/compute.go index 611a3cd28..904851c89 100644 --- a/pkg/opencost/compute.go +++ b/pkg/opencost/compute.go @@ -73,23 +73,8 @@ type SummaryOptions struct { NamespaceFilter string } -// ComputeCostSummary is the default compute path: asks OpenCost's REST API -// for namespace-level allocation over the window and maps the response into -// our normalized CostSummary. -// -// Why REST by default: OpenCost computes cost internally (cloud pricing + -// Kubernetes allocation data) and exposes the results two ways — REST at -// /allocation/assets/cloudCost and Prometheus metrics at /metrics. REST -// works wherever OpenCost works; the Prometheus path requires a scrape -// config that's often missing on clusters where OpenCost was installed -// manually. REST is also simpler (one pre-aggregated call instead of ~6 -// PromQL queries + client-side math). -// -// When to reach for ComputeCostSummaryFromProm instead: -// - You need custom label aggregations beyond what /allocation exposes. -// - You want per-node hourly pricing as time series. -// - You're correlating cost with live Prometheus metrics (deploy events, -// HPA state, container_cpu_usage, etc.) in the same query. +// ComputeCostSummary asks OpenCost's REST API for namespace-level allocation +// over the window and maps the response into our normalized CostSummary. // // Contract: // - REST unreachable or returns error → Available=false, Reason=ReasonQueryError. @@ -326,10 +311,8 @@ func safeRatio(num, den float64) float64 { return num / den } -// ComputeCostSummaryFromProm is the PromQL-based compute path, for callers -// that have a scraped-OpenCost Prometheus available rather than the REST -// API (or that need to correlate cost with live Prometheus metrics in the -// same query). +// ComputeCostSummaryFromProm is the PromQL-based compute path used by Radar's +// server handlers and other callers with scraped OpenCost metrics. // // Contract: // - If the primary OpenCost allocation metrics are absent entirely, the diff --git a/pkg/opencost/rest_client.go b/pkg/opencost/rest_client.go index 200759ec1..1ca081368 100644 --- a/pkg/opencost/rest_client.go +++ b/pkg/opencost/rest_client.go @@ -17,9 +17,9 @@ import ( // 2. Prometheus-format metrics at /metrics — requires a scrape config // in a reachable Prometheus instance. Covered by pkg/prom. // -// Many clusters have (1) working but (2) not wired up (Prometheus exists -// but no scrape job for OpenCost's /metrics). REST works everywhere OpenCost -// works, so it's the default compute path. +// This client supports callers that can reach OpenCost directly. Radar's server +// handlers currently use the Prometheus path because they already discover and +// connect to a cluster metrics backend. type RESTClient struct { t Transport } diff --git a/web/src/components/cost/ApplicationCostTab.tsx b/web/src/components/cost/ApplicationCostTab.tsx index f82df8890..efc98b3ce 100644 --- a/web/src/components/cost/ApplicationCostTab.tsx +++ b/web/src/components/cost/ApplicationCostTab.tsx @@ -317,7 +317,7 @@ export function ApplicationCostTab({
Powered by OpenCost via Prometheus.{' '} {currentCurrency !== DEFAULT_COST_CURRENCY && ( - <>Radar labels OpenCost values as {currentCurrency}; no currency conversion. + <>Labeled {currentCurrency}; no conversion. )} 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 diff --git a/web/src/components/cost/CostView.tsx b/web/src/components/cost/CostView.tsx index 5bb266fa6..1abc95be9 100644 --- a/web/src/components/cost/CostView.tsx +++ b/web/src/components/cost/CostView.tsx @@ -349,7 +349,7 @@ function CostOverview({ onBack, onOpenResource }: CostViewProps) { {currency} · current rates based on last 1h average · *monthly projections assume {COST_HOURS_PER_MONTH} hrs/mo {currency !== DEFAULT_COST_CURRENCY && ( - <> · Radar labels OpenCost values as {currency}; no currency conversion + <> · no conversion )} Powered by OpenCost @@ -815,9 +815,10 @@ function CostHelpDialog({ currency, onClose }: { currency: string; onClose: () = Which currency is shown?

- Values are labeled {currency}. If OpenCost prices your cluster in - another currency, start Radar with --opencost-currency (Helm:{' '} - cost.currency). Radar labels the values but does not convert them. + Radar labels these values {currency} and does not convert them. Auto + detects currencyCode from a running OpenCost pricing configuration when + Prometheus is cluster-discovered, then falls back to USD. Override it in Settings → Cost or, + for automation, with --opencost-currency (Helm: cost.currency).

diff --git a/web/src/components/cost/WorkloadCostTab.tsx b/web/src/components/cost/WorkloadCostTab.tsx index 4b6d366f5..d630d041e 100644 --- a/web/src/components/cost/WorkloadCostTab.tsx +++ b/web/src/components/cost/WorkloadCostTab.tsx @@ -227,7 +227,7 @@ export function WorkloadCostTab({ kind, namespace, name }: WorkloadCostTabProps)
Powered by OpenCost via Prometheus.{' '} {currentCurrency !== DEFAULT_COST_CURRENCY && ( - <>Radar labels OpenCost values as {currentCurrency}; no currency conversion. + <>Labeled {currentCurrency}; no conversion. )} Historical spend uses the selected range; projected monthly values multiply the current hourly allocation. Storage/PVC attribution remains at namespace and cluster level. diff --git a/web/src/components/cost/format.test.ts b/web/src/components/cost/format.test.ts index e7d3263b9..1500979df 100644 --- a/web/src/components/cost/format.test.ts +++ b/web/src/components/cost/format.test.ts @@ -35,8 +35,8 @@ describe('cost formatters', () => { expect(formatCostAxis(0.000001, 'GBP')).toBe('<£0.00001') }) - it('normalizes codes and falls back to USD for malformed currencies', () => { + it('normalizes codes and labels malformed currencies without impersonating USD', () => { expect(formatProjectedMonthlyCost(1, ' eur ')).toBe('~€730.00') - expect(formatProjectedMonthlyCost(1, 'not-a-code')).toBe('~$730.00') + expect(formatProjectedMonthlyCost(1, 'not-a-code')).toBe('~NOT-A-CODE 730.00') }) }) diff --git a/web/src/components/cost/format.ts b/web/src/components/cost/format.ts index dc4a6c456..a979c11a6 100644 --- a/web/src/components/cost/format.ts +++ b/web/src/components/cost/format.ts @@ -2,9 +2,11 @@ export const COST_HOURS_PER_DAY = 24 export const COST_HOURS_PER_MONTH = 730 export const DEFAULT_COST_CURRENCY = 'USD' -const currencyFormatters = new Map() +type CurrencyFormat = { formatter: Intl.NumberFormat; prefix: string } -function currencyFormatter(currency: string, digits?: number): Intl.NumberFormat { +const currencyFormatters = new Map() + +function currencyFormatter(currency: string, digits?: number): CurrencyFormat { const normalized = currency.trim().toUpperCase() || DEFAULT_COST_CURRENCY const key = `${normalized}:${digits ?? 'default'}` const cached = currencyFormatters.get(key) @@ -20,15 +22,27 @@ function currencyFormatter(currency: string, digits?: number): Intl.NumberFormat options.maximumFractionDigits = digits } const formatter = new Intl.NumberFormat('en-US', options) - currencyFormatters.set(key, formatter) - return formatter + const result = { formatter, prefix: '' } + currencyFormatters.set(key, result) + return result } catch { - return currencyFormatter(DEFAULT_COST_CURRENCY, digits) + const fallbackDigits = digits ?? 2 + const options: Intl.NumberFormatOptions = { + minimumFractionDigits: fallbackDigits, + maximumFractionDigits: fallbackDigits, + } + const result = { + formatter: new Intl.NumberFormat('en-US', options), + prefix: `${normalized} `, + } + currencyFormatters.set(key, result) + return result } } function formatCurrency(value: number, currency: string, digits?: number): string { - return currencyFormatter(currency, digits).format(value) + const { formatter, prefix } = currencyFormatter(currency, digits) + return `${prefix}${formatter.format(value)}` } export function formatCostAxis(value: number, currency: string): string { diff --git a/web/src/components/home/CostCard.tsx b/web/src/components/home/CostCard.tsx index df26efa38..4614d23d6 100644 --- a/web/src/components/home/CostCard.tsx +++ b/web/src/components/home/CostCard.tsx @@ -89,7 +89,7 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga {currency} · projected monthly from {data.window ?? '1h'} window {currency !== DEFAULT_COST_CURRENCY && ( - <> · Radar labels OpenCost values as {currency}; no currency conversion + <> · no conversion )} diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 8f560142a..be52db033 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -3,10 +3,11 @@ import { createPortal } from 'react-dom' import { Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug, Plus, Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap, - LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, + LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins, type LucideIcon, } from 'lucide-react' import { clsx } from 'clsx' +import { useQueryClient } from '@tanstack/react-query' import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount' import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation' import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config' @@ -73,17 +74,18 @@ interface SettingsDialogProps { } // The settings surface splits into three honest apply buckets: -// • Startup config (kubeconfig, server, timeline, MCP) — persisted by the -// owner-gated footer to the config file; effect on next launch. +// • Persisted config (kubeconfig, server, timeline, MCP, cost currency) — +// saved by the owner-gated footer. Currency applies live; the rest restart. // • Live integrations (Prometheus, Argo CD) — their own Apply/Connect endpoints // re-point the running server; effect immediately, NOT part of footer dirty. // • AI diagnose — client-side prefs, self-saving, editable by everyone. export type SettingsSectionId = - | 'overview' | 'perms' | 'connection' | 'prometheus' | 'argocd' | 'ai' | 'advanced' + | 'overview' | 'perms' | 'connection' | 'prometheus' | 'cost' | 'argocd' | 'ai' | 'advanced' -// Only STARTUP fields count toward footer dirty. Integration fields (prometheusUrl, -// argoCdUrl, argoCdInsecureTls) apply live and are excluded here. Every field is -// normalized so unset≡default doesn't read as a change. +// Persisted footer fields include startup settings plus the live currency override. +// Integration fields (prometheusUrl, argoCdUrl, argoCdInsecureTls) apply through +// their own controls and are excluded here. Every field is normalized so +// unset≡default doesn't read as a change. function normalizeStartup(c: Config) { return { kubeconfig: c.kubeconfig ?? '', @@ -96,6 +98,7 @@ function normalizeStartup(c: Config) { timelineDbPath: c.timelineDbPath ?? '', historyLimit: c.historyLimit ?? null, mcp: c.mcp ?? true, + opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '', } } @@ -104,6 +107,7 @@ export function SettingsDialog({ onClose, initialSection = 'overview', }: SettingsDialogProps) { + const queryClient = useQueryClient() const dialogRef = useRef(null) const { shouldRender, isOpen } = useAnimatedUnmount(open, 200) const { data: versionInfo } = useVersionCheck() @@ -157,10 +161,11 @@ export function SettingsDialog({ edN.timelineStorage !== svN.timelineStorage || edN.timelineDbPath !== svN.timelineDbPath || edN.historyLimit !== svN.historyLimit + const costDirty = edN.opencostCurrency !== svN.opencostCurrency // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline). const connectionDirty = clusterDirty || serverDirty const advancedDirty = mcpDirty || timelineDirty - const startupDirty = configData != null && (connectionDirty || advancedDirty) + const configDirty = configData != null && (connectionDirty || costDirty || advancedDirty) // Load config on open + snapshot AI prefs + pick a default section that's // actually accessible to the current identity. @@ -238,9 +243,21 @@ export function SettingsDialog({ setSaveMessage(`Error: ${data?.error || res.statusText}`) return false } - // Advance the committed snapshot so startupDirty settles to false. - setConfigData((prev) => (prev ? { ...prev, file: body } : prev)) - setSaveMessage('Saved. Restart Radar to apply.') + const saved = await res.json() as Config + const committed = { ...body, opencostCurrency: saved.opencostCurrency } + setEditedConfig(committed) + setConfigData((prev) => (prev ? { ...prev, file: committed } : prev)) + if (costDirty) { + void queryClient.invalidateQueries({ + predicate: (query) => + typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'), + }) + } + setSaveMessage(connectionDirty || advancedDirty + ? costDirty + ? 'Saved. Currency applied immediately; restart Radar for other changes.' + : 'Saved. Restart Radar to apply.' + : 'Saved. Applied immediately.') return true } catch (err) { setSaveMessage(`Error: ${err}`) @@ -248,7 +265,7 @@ export function SettingsDialog({ } finally { setSaving(false) } - }, [editedConfig, configData]) + }, [editedConfig, configData, costDirty, connectionDirty, advancedDirty, queryClient]) // AI prefs are client-side (localStorage) — commit the staged draft now. // setSelectedAgent clears model/effort (they're agent-specific), so set the @@ -281,7 +298,7 @@ export function SettingsDialog({ // drop it on close. Held in a ref so the ESC listener reads current dirtiness. const requestCloseRef = useRef<() => void>(() => {}) requestCloseRef.current = () => { - if (canEditConfig && startupDirty) setConfirmingClose(true) + if (canEditConfig && configDirty) setConfirmingClose(true) else onClose() } @@ -327,12 +344,13 @@ export function SettingsDialog({ { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false }, { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty }, { id: 'prometheus', label: 'Prometheus', icon: Activity, ownerOnly: true, dirty: false }, + { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costDirty }, { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false }, { id: 'ai', label: 'AI diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty }, { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty }, ] - const showFooter = canEditConfig && (confirmingClose || startupDirty || !!saveMessage) + const showFooter = canEditConfig && (confirmingClose || configDirty || !!saveMessage) return createPortal(
@@ -499,6 +517,20 @@ export function SettingsDialog({ /> + + updateConfigField('opencostCurrency', value || undefined)} + /> + + {/* Argo CD — live */}
- {/* Footer — owner-gated. Startup config only: AI self-saves, integrations - apply live. Shown whenever a startup edit is pending (any section), + {/* Footer — owner-gated persisted config. AI self-saves and integrations + apply separately. Shown whenever an edit is pending (any section), while confirming a close, or briefly after a save. */}
{open && ( -
- {options.map((option) => { - const active = option.value === value - return ( - - ) - })} + aria-label={searchPlaceholder} + placeholder={searchPlaceholder} + className="min-w-0 flex-1 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary" + /> +
+ )} +
+ {filteredOptions.length === 0 && ( +

No matches.

+ )} + {filteredOptions.map((option) => { + const active = option.value === value + return ( + + ) + })} +
)}
diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 2d2fff4e5..1fd9c2561 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -15,11 +15,12 @@ import { useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus, } from '../../api/client' import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext' -import { Input } from '@skyhook-io/k8s-ui' +import { Input, SelectMenu } from '@skyhook-io/k8s-ui' import { Tooltip } from '../ui/Tooltip' import { AISettingsSection, type AIDraft } from '../diagnose/AISettings' import { MyPermissionsContent } from './MyPermissionsDialog' import { useDiagnose } from '../diagnose/DiagnoseContext' +import { CURRENCY_OPTIONS } from './currency-options' // The loopback URL an MCP client is told to connect to. Shared by the overview // row and the MCP section: both must carry the base path, or the URL they @@ -1166,12 +1167,20 @@ function CostSection({ }) { return (
- + Currency override + +

+ Choose a currency, or use Auto to detect it from a running OpenCost pricing configuration + and then fall back to USD. Radar labels values but does not convert them. +

+ {managed && (

diff --git a/web/src/components/settings/currency-options.test.ts b/web/src/components/settings/currency-options.test.ts new file mode 100644 index 000000000..b1a61ce19 --- /dev/null +++ b/web/src/components/settings/currency-options.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { CURRENCY_OPTIONS } from './currency-options' + +describe('currency options', () => { + it('offers Auto followed by named ISO 4217 currencies', () => { + expect(CURRENCY_OPTIONS[0]).toEqual({ + value: '', + label: 'Auto (detect from OpenCost)', + }) + expect(CURRENCY_OPTIONS).toContainEqual({ + value: 'EUR', + label: 'Euro (EUR)', + }) + expect(CURRENCY_OPTIONS).toContainEqual({ + value: 'USD', + label: 'US Dollar (USD)', + }) + }) + + it('contains unique uppercase three-letter currency codes', () => { + const codes = CURRENCY_OPTIONS.slice(1).map((option) => option.value) + expect(new Set(codes).size).toBe(codes.length) + expect(codes.every((code) => /^[A-Z]{3}$/.test(code))).toBe(true) + }) +}) diff --git a/web/src/components/settings/currency-options.ts b/web/src/components/settings/currency-options.ts new file mode 100644 index 000000000..e6f183d8f --- /dev/null +++ b/web/src/components/settings/currency-options.ts @@ -0,0 +1,16 @@ +import type { SelectMenuOption } from '@skyhook-io/k8s-ui' + +const currencyNames = new Intl.DisplayNames(['en'], { type: 'currency' }) + +export const CURRENCY_OPTIONS: SelectMenuOption[] = [ + { value: '', label: 'Auto (detect from OpenCost)' }, + ...Intl.supportedValuesOf('currency') + .map((code) => { + const name = currencyNames.of(code); + return { + value: code, + label: name && name !== code ? `${name} (${code})` : code, + } + }) + .sort((a, b) => a.label.localeCompare(b.label, 'en')), +] From bda7b82b5ea49fc1efde9861cfb25b9b4f1ec80c Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 26 Aug 2026 01:10:25 +0300 Subject: [PATCH 05/18] fix: tighten currency picker behavior --- .../k8s-ui/src/components/ui/SelectMenu.tsx | 19 ++++++++++----- pkg/opencost/types.go | 16 ++++++------- web/src/api/client.ts | 16 ++++++------- .../cost/ApplicationCostTab.test.ts | 6 +++++ web/src/components/cost/CostTrendChart.tsx | 4 ++-- web/src/components/cost/CostView.tsx | 4 ++-- .../components/cost/WorkloadCostTab.test.ts | 10 ++++++++ web/src/components/home/CostCard.tsx | 2 +- .../components/settings/SettingsDialog.tsx | 23 +++++++++++-------- .../settings/currency-options.test.ts | 2 ++ .../components/settings/currency-options.ts | 1 + 11 files changed, 67 insertions(+), 36 deletions(-) diff --git a/packages/k8s-ui/src/components/ui/SelectMenu.tsx b/packages/k8s-ui/src/components/ui/SelectMenu.tsx index 498a1eee0..fcc18f3f8 100644 --- a/packages/k8s-ui/src/components/ui/SelectMenu.tsx +++ b/packages/k8s-ui/src/components/ui/SelectMenu.tsx @@ -25,6 +25,7 @@ export function SelectMenu({ const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const rootRef = useRef(null) + const triggerRef = useRef(null) const listRef = useRef(null) const selected = options.find((option) => option.value === value) ?? options[0] const filteredOptions = useMemo(() => { @@ -38,14 +39,9 @@ export function SelectMenu({ const onPointerDown = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false) } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setOpen(false) - } document.addEventListener('mousedown', onPointerDown) - document.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('mousedown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) } }, [open]) @@ -54,8 +50,19 @@ export function SelectMenu({ }, [open]) return ( -

+
{ + if (event.key !== 'Escape' || !open) return + event.preventDefault() + event.stopPropagation() + setOpen(false) + triggerRef.current?.focus() + }} + > {open && (
{ + if (!rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false) + }} className={clsx( 'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg', searchPlaceholder ? 'left-0 right-0' : 'right-0' @@ -144,14 +147,21 @@ export function SelectMenu({ aria-autocomplete="list" aria-controls={listboxId} aria-expanded="true" + aria-activedescendant={ + filteredOptions.length > 0 + ? `${listboxId}-option-${Math.min(highlightedIndex, filteredOptions.length - 1)}` + : undefined + } placeholder={searchPlaceholder} className="min-w-0 flex-1 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary" />
)} - {searchPlaceholder && filteredOptions.length > 0 && ( + {searchPlaceholder && ( - {filteredOptions.length} {filteredOptions.length === 1 ? 'option' : 'options'} available. + {filteredOptions.length === 0 + ? 'No matches.' + : `${filteredOptions.length} ${filteredOptions.length === 1 ? 'option' : 'options'} available.`} )}
{filteredOptions.length === 0 && ( -

+

No matches.

)} From 7cdbbda968fed31c32e9a4b0cb585d777bb4fb1e Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 26 Aug 2026 03:40:32 +0300 Subject: [PATCH 13/18] fix: close currency picker on focus exit --- packages/k8s-ui/src/components/ui/SelectMenu.tsx | 13 +++++++++---- web/src/components/settings/SettingsDialog.tsx | 3 ++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/k8s-ui/src/components/ui/SelectMenu.tsx b/packages/k8s-ui/src/components/ui/SelectMenu.tsx index 77879e38a..dd8267bd4 100644 --- a/packages/k8s-ui/src/components/ui/SelectMenu.tsx +++ b/packages/k8s-ui/src/components/ui/SelectMenu.tsx @@ -86,6 +86,9 @@ export function SelectMenu({ setOpen(false) triggerRef.current?.focus() }} + onBlur={(event) => { + if (open && !rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false) + }} >
{filteredOptions.length === 0 && ( -

+

event.preventDefault()} + className="px-3 py-2 text-xs text-theme-text-tertiary" + > No matches.

)} From 3aa7224e9e53d8912f129812b6e2578418c8e1cb Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 26 Aug 2026 04:33:53 +0300 Subject: [PATCH 16/18] fix: preserve select menu focus during scrolling --- .../k8s-ui/src/components/ui/SelectMenu.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/k8s-ui/src/components/ui/SelectMenu.tsx b/packages/k8s-ui/src/components/ui/SelectMenu.tsx index ac2e998a5..e292abadf 100644 --- a/packages/k8s-ui/src/components/ui/SelectMenu.tsx +++ b/packages/k8s-ui/src/components/ui/SelectMenu.tsx @@ -30,6 +30,7 @@ export function SelectMenu({ const triggerRef = useRef(null) const searchInputRef = useRef(null) const listRef = useRef(null) + const pointerDownInsideRef = useRef(false) const selected = options.find((option) => option.value === value) ?? options[0] const filteredOptions = useMemo(() => { const normalized = query.trim().toLowerCase() @@ -79,6 +80,12 @@ export function SelectMenu({
{ + pointerDownInsideRef.current = true + requestAnimationFrame(() => { + pointerDownInsideRef.current = false + }) + }} onKeyDown={(event) => { if (event.key !== 'Escape' || !open) return event.preventDefault() @@ -87,7 +94,17 @@ export function SelectMenu({ triggerRef.current?.focus() }} onBlur={(event) => { - if (open && event.relatedTarget && !rootRef.current?.contains(event.relatedTarget)) setOpen(false) + if (!open) return + if (event.relatedTarget && rootRef.current?.contains(event.relatedTarget)) return + if (!pointerDownInsideRef.current) { + setOpen(false) + return + } + requestAnimationFrame(() => { + if (!document.hasFocus() || !listRef.current) return + if (searchPlaceholder) searchInputRef.current?.focus() + else triggerRef.current?.focus() + }) }} >