diff --git a/README.md b/README.md index 9bafc8a02..cf22f163f 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` | (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 | @@ -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 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 6fbc408df..fcbc54a90 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, "Override the ISO 4217 currency label for OpenCost values (empty: auto-detect, then USD)") flag.Parse() if *showVersion { @@ -107,12 +108,15 @@ func main() { } namespaceFlagSet := false namespacesFlagSet := false + openCostCurrencyFlagSet := false flag.Visit(func(f *flag.Flag) { switch f.Name { case "namespace": namespaceFlagSet = true case "namespaces": namespacesFlagSet = true + case "opencost-currency": + openCostCurrencyFlagSet = true } }) timelineMaxSizeBytes, err := config.ParseByteSize(*timelineMaxSize) @@ -120,6 +124,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 +172,8 @@ func main() { TimelineRetention: *timelineRetention, TimelineMaxSizeBytes: timelineMaxSizeBytes, PrometheusURL: *prometheusURL, + OpenCostCurrency: normalizedOpenCostCurrency, + OpenCostFlagSet: openCostCurrencyFlagSet, PrometheusHeaders: resolvedPrometheusHeaders, PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv, Version: version, diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index 7cd835729..052285860 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, "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). @@ -270,9 +271,14 @@ 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 + openCostCurrencyFlagSet := false flag.Visit(func(f *flag.Flag) { switch f.Name { case "no-mcp": @@ -281,6 +287,8 @@ func main() { namespaceFlagSet = true case "namespaces": namespacesFlagSet = true + case "opencost-currency": + openCostCurrencyFlagSet = true } }) if *mcpCatalogOnly && noMCPFlagSet && *noMCP { @@ -349,6 +357,8 @@ func main() { TimelineRetention: *timelineRetention, TimelineMaxSizeBytes: timelineMaxSizeBytes, PrometheusURL: *prometheusURL, + OpenCostCurrency: normalizedOpenCostCurrency, + OpenCostFlagSet: openCostCurrencyFlagSet, PrometheusHeaders: resolvedPrometheusHeaders, PrometheusHeadersFromEnv: promHeadersFromEnv.value(), BeylaJobSelector: *beylaJobSelector, diff --git a/deploy/helm/radar/README.md b/deploy/helm/radar/README.md index e0d7a001c..d0c807590 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` | 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/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..81795ddca 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": "Optional ISO 4217 override for OpenCost values; empty auto-detects from a running OpenCost pricing config, then uses USD." + } + } + }, "traffic": { "type": "object", "additionalProperties": true, diff --git a/deploy/helm/radar/values.yaml b/deploy/helm/radar/values.yaml index edb679016..a6cca5ce9 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: + # 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 traffic: # Manual Prometheus/VictoriaMetrics URL (bypasses auto-discovery) diff --git a/docs/configuration.md b/docs/configuration.md index f3f9fb969..84b6ee7b3 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": "", "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` | 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 while Radar runs and 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 90eb27951..a1a516b9c 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. 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 while Radar runs and after restart. Radar labels the values but does not convert them. ### What Radar Shows 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..6731fb2b3 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -59,6 +59,8 @@ type AppConfig struct { TimelineRetention time.Duration TimelineMaxSizeBytes int64 PrometheusURL string + OpenCostCurrency string + OpenCostFlagSet bool PrometheusHeaders map[string]string PrometheusHeadersFromEnv map[string]string BeylaJobSelector string @@ -275,6 +277,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 +295,8 @@ func CreateServer(cfg AppConfig) *server.Server { StaticFS: static.FS, StaticRoot: "dist", EffectiveConfig: effectiveCfg, + OpenCostCurrency: cfg.OpenCostCurrency, + OpenCostManaged: cfg.OpenCostFlagSet, DiagConfig: &server.DiagConfig{ Port: cfg.Port, DevMode: cfg.DevMode, @@ -300,6 +305,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..3a93588fd --- /dev/null +++ b/internal/config/opencost.go @@ -0,0 +1,23 @@ +package config + +import ( + "fmt" + "strings" + + "golang.org/x/text/currency" +) + +func NormalizeOpenCostCurrency(raw string) (string, error) { + code := strings.ToUpper(strings.TrimSpace(raw)) + if code == "" { + return "", 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..774fffe8d --- /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: "auto", want: ""}, + {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/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 a2b0eaa55..3d2bf6fc6 100644 --- a/internal/opencost/handlers.go +++ b/internal/opencost/handlers.go @@ -17,27 +17,29 @@ 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, 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) { +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}) + 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)}) + writeJSON(w, http.StatusOK, pkgopencost.CostSummary{Available: false, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) return } - writeJSON(w, http.StatusOK, pkgopencost.ComputeCostSummaryFromProm( - r.Context(), client.Prom(), pkgopencost.SummaryOptions{})) + currency := resolvedCurrency(resolveCurrency) + 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 +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) { +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"}) @@ -58,17 +60,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: 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)}) + writeJSON(w, http.StatusOK, pkgopencost.WorkloadCostResponse{Namespace: ns, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) 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 = resolvedCurrency(resolveCurrency) + writeJSON(w, http.StatusOK, resp) } // BuildPodOwnerLookup snapshots radar's pod informer for `ns` so @@ -117,38 +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) { +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}) + 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)}) + writeJSON(w, http.StatusOK, pkgopencost.CostTrendResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) 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 = resolvedCurrency(resolveCurrency) + 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, resolveCurrency func() 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: 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)}) + writeJSON(w, http.StatusOK, pkgopencost.NodeCostResponse{Available: false, Reason: ConnectionFailureReason(err), Currency: resolvedCurrency(resolveCurrency)}) return } resp := pkgopencost.ComputeNodeCosts(r.Context(), client.Prom()) + 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 new file mode 100644 index 000000000..986d2ca55 --- /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, func() 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, func() string { return "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, func() 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), func() string { return "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/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 d130141df..040781d14 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"` } @@ -523,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 79a1ac09b..6243d1e40 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.resolvedOpenCostCurrency() + 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.resolvedOpenCostCurrency() + 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.resolvedOpenCostCurrency() + 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.resolvedOpenCostCurrency() + 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.resolvedOpenCostCurrency() + 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.resolvedOpenCostCurrency() + 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..4e715eb87 100644 --- a/internal/server/opencost_workload.go +++ b/internal/server/opencost_workload.go @@ -45,6 +45,7 @@ func (s *Server) handleOpenCostWorkload(w http.ResponseWriter, r *http.Request) if client == nil { resp.Available = false resp.Reason = pkgopencost.ReasonNoPrometheus + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -52,12 +53,15 @@ 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)) - s.writeJSON(w, focusOpenCostWorkload(workloads, kind, namespace, name, desiredReplicas)) + result := focusOpenCostWorkload(workloads, kind, namespace, name, desiredReplicas) + result.Currency = s.resolvedOpenCostCurrency() + s.writeJSON(w, result) } func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Request) { @@ -84,6 +88,7 @@ func (s *Server) handleOpenCostWorkloadTrend(w http.ResponseWriter, r *http.Requ if client == nil { resp.Available = false resp.Reason = pkgopencost.ReasonNoPrometheus + resp.Currency = s.resolvedOpenCostCurrency() s.writeJSON(w, resp) return } @@ -91,16 +96,26 @@ 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 } - 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.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) { diff --git a/internal/server/opencost_workload_test.go b/internal/server/opencost_workload_test.go index 887a82336..aeaaec325 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: internalopencost.NewCurrencyResolver("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..c99c37535 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -82,6 +82,8 @@ type Server struct { mcpReadOnlyHandler http.Handler diagConfig *DiagConfig effectiveConfig *config.Config // running config for GET /api/config + openCostCurrency *opencost.CurrencyResolver + currencyManaged bool authConfig auth.Config permCache *auth.PermissionCache oidcHandler *auth.OIDCHandler @@ -166,6 +168,8 @@ 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 + OpenCostManaged bool // true when an explicit CLI/Helm flag owns the running value AuthConfig auth.Config // Authentication configuration AIHistoryDB string // AI run-history SQLite path ("" = memory-only runs) CloudConnect CloudConnectConfig @@ -198,6 +202,8 @@ func New(cfg Config) *Server { mcpReadOnlyHandler: cfg.MCPReadOnlyHandler, diagConfig: cfg.DiagConfig, effectiveConfig: cfg.EffectiveConfig, + openCostCurrency: opencost.NewCurrencyResolver(cfg.OpenCostCurrency), + currencyManaged: cfg.OpenCostManaged, authConfig: cfg.AuthConfig, cloudConnectCfg: cfg.CloudConnect, topoMemo: topology.NewMemoizer(5 * time.Second), @@ -706,7 +712,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.resolvedOpenCostCurrency) // FluxCD routes r.Post("/flux/{kind}/{namespace}/{name}/reconcile", s.handleFluxReconcile) @@ -5098,6 +5104,9 @@ type configResponse struct { File config.Config `json:"file"` Effective config.Config `json:"effective"` IsDesktop bool `json:"isDesktop"` + // OpenCostManaged tells Settings that an explicit startup flag owns the + // running value even when the persisted file changes. + OpenCostManaged bool `json:"openCostCurrencyManaged,omitempty"` // PrometheusHeaderKeys lists the configured Prometheus header names so the UI // can show what's set without ever receiving the (secret) values. PrometheusHeaderKeys []string `json:"prometheusHeaderKeys,omitempty"` @@ -5152,6 +5161,7 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { resp := configResponse{ File: file, IsDesktop: version.IsDesktop(), + OpenCostManaged: s.currencyManaged, PrometheusHeaderKeys: headerKeys, ArgoCDTokenSet: tokenSet, ArgoCDEnvManaged: envManaged, @@ -5171,7 +5181,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 unless an explicit startup flag owns it. // 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 @@ -5185,6 +5196,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 @@ -5217,6 +5234,9 @@ func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) { s.writeError(w, http.StatusInternalServerError, err.Error()) return } + if s.openCostCurrency != nil && !s.currencyManaged { + s.openCostCurrency.SetOverride(result.OpenCostCurrency) + } result.PrometheusHeaders = nil result.ArgoCDToken = "" s.writeJSON(w, result) @@ -5292,6 +5312,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 5377f0b10..8fa176826 100644 --- a/internal/server/settings_role_test.go +++ b/internal/server/settings_role_test.go @@ -8,6 +8,8 @@ import ( "testing" "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 @@ -16,6 +18,116 @@ func userWithGroups(groups ...string) *auth.User { return &auth.User{Username: "u@example.com", Groups: groups} } +func TestPutConfigPersistsAndAppliesOpenCostCurrency(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + s := &Server{openCostCurrency: internalopencost.NewCurrencyResolver("JPY")} + 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) + } + 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 TestPutConfigDoesNotReplaceManagedOpenCostCurrency(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + s := &Server{ + openCostCurrency: internalopencost.NewCurrencyResolver("GBP"), + currencyManaged: true, + } + r := httptest.NewRequest(http.MethodPut, "/api/config", strings.NewReader(`{"opencostCurrency":"JPY"}`)) + 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 != "JPY" { + t.Errorf("persisted opencostCurrency = %q, want JPY", got) + } + if got := s.openCostCurrency.Resolve(); got != "GBP" { + t.Errorf("running currency = %q, want managed GBP", got) + } +} + +func TestGetConfigReportsManagedOpenCostCurrency(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{ + effectiveConfig: &config.Config{OpenCostCurrency: "GBP"}, + currencyManaged: true, + } + w := httptest.NewRecorder() + + s.handleGetConfig(w, httptest.NewRequest(http.MethodGet, "/api/config", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var body struct { + Effective struct { + OpenCostCurrency string `json:"opencostCurrency"` + } `json:"effective"` + Managed bool `json:"openCostCurrencyManaged"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Managed || body.Effective.OpenCostCurrency != "GBP" { + t.Fatalf("managed config response = %+v, want managed GBP", body) + } +} + +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/ui/SelectMenu.tsx b/packages/k8s-ui/src/components/ui/SelectMenu.tsx index 681cd611f..498a1eee0 100644 --- a/packages/k8s-ui/src/components/ui/SelectMenu.tsx +++ b/packages/k8s-ui/src/components/ui/SelectMenu.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { Check, ChevronDown } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Search } from 'lucide-react' import { clsx } from 'clsx' export interface SelectMenuOption { @@ -13,16 +13,25 @@ export function SelectMenu({ onChange, ariaLabel, className, + searchPlaceholder, }: { value: string options: SelectMenuOption[] onChange: (value: string) => void ariaLabel: string className?: string + searchPlaceholder?: string }) { const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') const rootRef = useRef(null) + const listRef = useRef(null) const selected = options.find((option) => option.value === value) ?? options[0] + const filteredOptions = useMemo(() => { + const normalized = query.trim().toLowerCase() + if (!normalized) return options + return options.filter((option) => option.label.toLowerCase().includes(normalized)) + }, [options, query]) useEffect(() => { if (!open) return @@ -40,6 +49,10 @@ export function SelectMenu({ } }, [open]) + useEffect(() => { + if (!open) setQuery('') + }, [open]) + return (
{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/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..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. @@ -99,7 +84,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" @@ -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 @@ -339,11 +322,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 +340,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 +352,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/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/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..efc98b3ce 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 && ( + <>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 + 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..1abc95be9 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,19 @@ 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? +

+

+ 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/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..d630d041e 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 && ( + <>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.
) @@ -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..1500979df 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 labels malformed currencies without impersonating USD', () => { + expect(formatProjectedMonthlyCost(1, ' eur ')).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 3dceaf69e..a979c11a6 100644 --- a/web/src/components/cost/format.ts +++ b/web/src/components/cost/format.ts @@ -1,46 +1,97 @@ 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' +type CurrencyFormat = { formatter: Intl.NumberFormat; prefix: string } + +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) + 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) + const result = { formatter, prefix: '' } + currencyFormatters.set(key, result) + return result + } catch { + 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 { + const { formatter, prefix } = currencyFormatter(currency, digits) + return `${prefix}${formatter.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..4614d23d6 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 && ( + <> · no 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..1fd9c2561 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' @@ -14,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 @@ -39,6 +41,7 @@ interface Config { timelineDbPath?: string historyLimit?: number prometheusUrl?: string + opencostCurrency?: string argoCdUrl?: string argoCdInsecureTls?: boolean mcp?: boolean | null @@ -48,6 +51,7 @@ interface ConfigResponse { file: Config effective: Config isDesktop: boolean + openCostCurrencyManaged?: boolean prometheusHeaderKeys?: string[] // True when an Argo CD auth token is stored. The token itself is never // returned — the card shows a "configured" placeholder and omits the token @@ -72,17 +76,19 @@ 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 unless a startup +// flag owns it; 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 ?? '', @@ -95,6 +101,7 @@ function normalizeStartup(c: Config) { timelineDbPath: c.timelineDbPath ?? '', historyLimit: c.historyLimit ?? null, mcp: c.mcp ?? true, + opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '', } } @@ -103,6 +110,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() @@ -156,10 +164,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. @@ -237,9 +246,27 @@ 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 && !configData.openCostCurrencyManaged) { + void queryClient.invalidateQueries({ + predicate: (query) => + typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'), + }) + } + if (costDirty && configData.openCostCurrencyManaged) { + setSaveMessage(connectionDirty || advancedDirty + ? 'Saved. CLI/Helm currency remains active; restart without that override to apply it. Restart Radar for other changes.' + : 'Saved. CLI/Helm currency remains active; restart without that override to apply this setting.') + } else { + 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}`) @@ -247,7 +274,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 @@ -280,7 +307,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() } @@ -326,12 +353,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(
@@ -498,6 +526,24 @@ 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. */}