@skyhook-io/radar-app 1.10.0 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -1,15 +1,21 @@
1
1
  import { renderToStaticMarkup } from 'react-dom/server'
2
2
  import type { ReactNode } from 'react'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
+ import type { ContextInfo } from '../types'
4
5
 
5
6
  vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
6
7
 
8
+ const useContextsMock = vi.hoisted(() => vi.fn(
9
+ (): { data: ContextInfo[] | undefined } => ({ data: undefined }),
10
+ ))
11
+
7
12
  vi.mock('@skyhook-io/k8s-ui', () => ({
8
13
  ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
9
14
  useOpenLocalTerminal: () => vi.fn(),
10
15
  }))
11
16
  vi.mock('../api/client', () => ({
12
17
  useAuthMe: () => ({ data: { authEnabled: false } }),
18
+ useContexts: useContextsMock,
13
19
  }))
14
20
  vi.mock('./ContextSwitcher', () => ({
15
21
  ContextSwitcher: () => <button>Switch context</button>,
@@ -72,6 +78,34 @@ describe('ConnectionErrorView authentication guidance', () => {
72
78
  expect(markup).not.toContain('aws sso login')
73
79
  })
74
80
 
81
+ it('uses the original context for collision-qualified auth guidance', () => {
82
+ const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
83
+ const hints = selectConnectionHints('auth', `${original} (secondary)`, original)
84
+
85
+ expect(hints?.title).toBe('EKS Authentication Failed')
86
+ expect(hints?.fallbackCommand?.command).toBe(
87
+ 'aws eks update-kubeconfig --name prod --region us-east-1',
88
+ )
89
+ })
90
+
91
+ it('wires the current context original name into auth guidance', () => {
92
+ const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
93
+ useContextsMock.mockReturnValueOnce({
94
+ data: [{
95
+ name: `${original} (secondary)`,
96
+ originalName: original,
97
+ cluster: original,
98
+ user: 'prod',
99
+ namespace: '',
100
+ isCurrent: true,
101
+ }],
102
+ })
103
+
104
+ const markup = renderError('auth', `${original} (secondary)`)
105
+ expect(markup).toContain('update-kubeconfig')
106
+ expect(markup).toContain('us-east-1')
107
+ })
108
+
75
109
  it('renders placeholder AKS commands without a run affordance', () => {
76
110
  const markup = renderError('auth-rejected', 'clusterUser_platform_prod')
77
111
 
@@ -86,3 +120,22 @@ describe('ConnectionErrorView authentication guidance', () => {
86
120
  expect(markup).not.toContain('aria-label="Run command in terminal"')
87
121
  })
88
122
  })
123
+
124
+ describe('ConnectionErrorView kubeconfig guidance', () => {
125
+ it('keeps the actionable error and context switch visible for a broken context', () => {
126
+ const markup = renderError('config', 'prod')
127
+
128
+ expect(markup).toContain('Cannot Load Cluster Configuration')
129
+ expect(markup).toContain('Kubeconfig Problem')
130
+ expect(markup).toContain('aria-expanded="false"')
131
+ expect(markup).toContain('id="connection-raw-error"')
132
+ expect(markup).toContain('safe error')
133
+ expect(markup).toContain('Switch context')
134
+ })
135
+
136
+ it('does not offer context switching when no context was loaded', () => {
137
+ const markup = renderError('config', '')
138
+
139
+ expect(markup).not.toContain('Switch context')
140
+ })
141
+ })
@@ -4,7 +4,7 @@ import type { ConnectionState } from '../context/ConnectionContext'
4
4
  import { ContextSwitcher } from './ContextSwitcher'
5
5
  import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
- import { useAuthMe } from '../api/client'
7
+ import { useAuthMe, useContexts } from '../api/client'
8
8
  import { Tooltip } from './ui/Tooltip'
9
9
  import { allShellSafe } from '../utils/shell-safe'
10
10
  import { apiUrl } from '../api/config'
@@ -218,11 +218,11 @@ function getTimeoutHints(context: string): AuthHints | null {
218
218
 
219
219
  const errorHints: Record<string, { title: string; hints: string[] }> = {
220
220
  config: {
221
- title: 'No Kubeconfig Found',
221
+ title: 'Kubeconfig Problem',
222
222
  hints: [
223
- 'Radar could not find a kubeconfig file at ~/.kube/config',
224
- 'If your kubeconfig is at a custom path, set the KUBECONFIG environment variable in your shell profile (~/.zshrc or ~/.bashrc)',
225
- 'You can also pass --kubeconfig <path> when launching from the terminal',
223
+ 'Radar could not load a usable kubeconfig for this context',
224
+ 'If the file exists, check the local Radar logs for the exact parse or load failure',
225
+ 'If no kubeconfig is configured, Radar checks ~/.kube/config; set KUBECONFIG or pass --kubeconfig <path> for another location',
226
226
  ],
227
227
  },
228
228
  rbac: {
@@ -321,16 +321,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
321
321
  )
322
322
  }
323
323
 
324
- export function selectConnectionHints(errorType: string | undefined, context: string): AuthHints | null {
324
+ export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
325
+ const parsedContext = originalContext || context
325
326
  switch (errorType) {
326
327
  case 'auth':
327
- return getAuthHints(context)
328
+ return getAuthHints(parsedContext)
328
329
  case 'auth-rejected':
329
- return getAuthRejectedHints(context)
330
+ return getAuthRejectedHints(parsedContext)
330
331
  case 'auth-plugin-stuck':
331
332
  return getAuthPluginStuckHints()
332
333
  case 'timeout':
333
- return getTimeoutHints(context)
334
+ return getTimeoutHints(parsedContext)
334
335
  default:
335
336
  return null
336
337
  }
@@ -342,7 +343,9 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
342
343
  const isAuthRejected = connection.errorType === 'auth-rejected'
343
344
  const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
344
345
  const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
345
- const commandInfo = selectConnectionHints(connection.errorType, connection.context || '')
346
+ const { data: contexts } = useContexts()
347
+ const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
348
+ const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
346
349
  const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
347
350
  const openLocalTerminal = useOpenLocalTerminal()
348
351
  const { data: authMe } = useAuthMe()
@@ -384,7 +387,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
384
387
  </div>
385
388
 
386
389
  <h2 className="text-xl font-semibold text-theme-text-primary mb-2">
387
- {connection.errorType === 'config' ? 'No Cluster Configuration' : 'Cannot Connect to Cluster'}
390
+ {connection.errorType === 'config' ? 'Cannot Load Cluster Configuration' : 'Cannot Connect to Cluster'}
388
391
  </h2>
389
392
 
390
393
  <div className="mb-6 space-y-1">
@@ -480,7 +483,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
480
483
  )}
481
484
  </button>
482
485
 
483
- {connection.errorType !== 'config' && <ContextSwitcher triggerName="Switch context" />}
486
+ {connection.context && <ContextSwitcher triggerName="Switch context" />}
484
487
  </div>
485
488
 
486
489
  {isAuthError && (
@@ -10,7 +10,7 @@ import { useContextSwitch } from '../context/ContextSwitchContext'
10
10
  import { useToast } from '../components/ui/Toast'
11
11
  import { useDock } from '../components/dock'
12
12
  import type { ContextInfo } from '../types'
13
- import { parseContextName, type ParsedContextName } from '../utils/context-name'
13
+ import { parseContextForSwitcher, visibleContextQualifier, type ParsedContextName } from '../utils/context-name'
14
14
 
15
15
  interface ContextSwitcherProps {
16
16
  className?: string
@@ -25,6 +25,7 @@ export interface ContextSwitcherHandle {
25
25
 
26
26
  interface ParsedContext extends ParsedContextName {
27
27
  context: ContextInfo
28
+ nameQualifier?: string
28
29
  }
29
30
 
30
31
  function shouldSuppressSwitchErrorToast(error: unknown): boolean {
@@ -54,18 +55,9 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
54
55
  hasMultipleAccounts: false,
55
56
  hasMultipleSources: false,
56
57
  }
57
- // Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
58
- // before parsing — qualified names won't match the GKE/EKS/AKS regexes
59
- // otherwise, and the suffix is redundant with the source chip we
60
- // render separately.
61
- const stripSourceSuffix = (name: string, source?: string): string => {
62
- if (!source) return name
63
- const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
- return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
65
- }
66
58
  const parsed: ParsedContext[] = contexts.map(ctx => ({
67
59
  context: ctx,
68
- ...parseContextName(stripSourceSuffix(ctx.name, ctx.source)),
60
+ ...parseContextForSwitcher(ctx),
69
61
  }))
70
62
  const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
71
63
  const sources = new Set(contexts.map(c => c.source).filter(Boolean))
@@ -100,6 +92,7 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
100
92
  return {
101
93
  id: p.context.name,
102
94
  name: p.raw,
95
+ nameQualifier: visibleContextQualifier(p.nameQualifier, p.context.source, hasMultipleSources),
103
96
  secondary: p.provider ? p.raw : undefined,
104
97
  badge: p.region || undefined,
105
98
  sourceLabel: hasMultipleSources ? p.context.source : undefined,
@@ -188,12 +181,15 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
188
181
 
189
182
  const currentCtx = contexts?.find(c => c.isCurrent)
190
183
  const currentId = currentCtx?.name
191
- // Use parsed.raw (the source-stripped form) for the trigger so the
192
- // disambiguation suffix doesn't double up with the source chip.
184
+ // Keep the trigger name parseable and render any collision qualifier as a
185
+ // separate suffix so cloud-provider metadata remains intact.
193
186
  // Fall back to clusterInfo.context for the very-early window before
194
187
  // /api/contexts has resolved.
195
188
  const currentParsed = currentId ? parsedById.get(currentId) : undefined
196
189
  const currentRaw = triggerName || currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
190
+ const currentNameQualifier = triggerName
191
+ ? undefined
192
+ : visibleContextQualifier(currentParsed?.nameQualifier, currentCtx?.source, hasMultipleSources)
197
193
  const currentSourceLabel = triggerName ? undefined : hasMultipleSources ? currentCtx?.source || undefined : undefined
198
194
 
199
195
  return (
@@ -205,6 +201,7 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
205
201
  label={label}
206
202
  currentId={currentId}
207
203
  currentName={currentRaw}
204
+ currentNameQualifier={currentNameQualifier}
208
205
  currentSourceLabel={currentSourceLabel}
209
206
  items={items}
210
207
  onSelect={handleSelect}
@@ -76,7 +76,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
76
76
  const location = useLocation()
77
77
  const navigate = useNavigate()
78
78
  const requestedTarget = new URLSearchParams(location.search).get('target') ?? undefined
79
- const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt, refetch } = useUpgradeReadiness(requestedTarget)
79
+ const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt, refreshScan } = useUpgradeReadiness(requestedTarget)
80
80
  const { connection } = useConnection()
81
81
  const targetOptions = useMemo(
82
82
  () => buildTargetOptions(data?.currentVersion, data?.reviewedThrough, requestedTarget ?? data?.targetVersion),
@@ -153,8 +153,8 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
153
153
  )}
154
154
  <FreshnessControl
155
155
  mode="snapshot"
156
- dataUpdatedAt={dataUpdatedAt}
157
- onRefresh={() => refetch()}
156
+ dataUpdatedAt={data.observedAt ? Date.parse(data.observedAt) : dataUpdatedAt}
157
+ onRefresh={() => refreshScan()}
158
158
  connectionState={connection.state}
159
159
  isFetching={isFetching}
160
160
  />
@@ -7,7 +7,7 @@ import {
7
7
  import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
8
8
  import {
9
9
  parseCPUToNanocores,
10
- parseMemoryToBytes,
10
+ parseQuantityToNumber,
11
11
  } from "@skyhook-io/k8s-ui/utils/format";
12
12
  import {
13
13
  CertaintyGlyph,
@@ -96,16 +96,15 @@ export function quantityToNumber(
96
96
  }
97
97
  return null;
98
98
  }
99
- // Milli quantities before the byte parserit ignores the `m` suffix and
100
- // would read 3000m as 3000, a 1000× distortion.
101
- const milli = /^(-?\d+(?:\.\d+)?)m$/.exec(value);
102
- if (milli) return Number(milli[1]) / 1000;
103
- const parsed = parseMemoryToBytes(value);
104
- if (Number.isFinite(parsed) && (parsed !== 0 || /^[0.]+\D*$/.test(value))) {
99
+ // The shared parser knows the full suffix table the byte parser stops at
100
+ // Ti, so a 1PiB aggregate would read as 1 byte. It returns 0 for input it
101
+ // cannot parse, so distinguish a real zero to preserve the null (= unknown,
102
+ // not zero) contract of this card.
103
+ const parsed = parseQuantityToNumber(value);
104
+ if (parsed !== 0 || /^[+-]?[0.]+\D*$/.test(value)) {
105
105
  return parsed;
106
106
  }
107
- const plain = Number(value);
108
- return Number.isFinite(plain) ? plain : null;
107
+ return null;
109
108
  }
110
109
 
111
110
  function worstCertainty(
@@ -202,6 +202,16 @@ describe("quantityToNumber", () => {
202
202
  expect(quantityToNumber("example.com/widget", "3000m")).toBe(3);
203
203
  expect(quantityToNumber("cpu", "1500m")).toBe(1.5e9);
204
204
  });
205
+
206
+ // Aggregate memory on a large cluster reaches suffixes the byte parser
207
+ // never knew (its table stops at Ti) — "1Pi" must not read as 1 byte.
208
+ it("parses the large suffixes an aggregate capacity can carry", () => {
209
+ expect(quantityToNumber("memory", "1Pi")).toBe(1024 ** 5);
210
+ expect(quantityToNumber("memory", "1Ei")).toBe(1024 ** 6);
211
+ expect(quantityToNumber("pods", "1k")).toBe(1000);
212
+ expect(quantityToNumber("pods", "1e3")).toBe(1000);
213
+ expect(quantityToNumber("memory", "0Gi")).toBe(0);
214
+ });
205
215
  });
206
216
 
207
217
  describe("claimStagesDetail", () => {
@@ -11,6 +11,7 @@ describe('getApplicationCostState', () => {
11
11
  it('keeps current app cost visible when historical owner metrics are missing', () => {
12
12
  const current: OpenCostApplicationCostResponse = {
13
13
  available: true,
14
+ currency: 'USD',
14
15
  partial: true,
15
16
  totals: {
16
17
  hourlyCost: 0.4,
@@ -38,6 +39,7 @@ describe('getApplicationCostState', () => {
38
39
  }
39
40
  const trend: OpenCostApplicationCostTrendResponse = {
40
41
  available: false,
42
+ currency: 'USD',
41
43
  reason: 'no_metrics',
42
44
  range: '24h',
43
45
  coverage: { total: 3, included: 0 },
@@ -54,6 +56,7 @@ describe('getApplicationCostState', () => {
54
56
  it('uses historical data when current app metrics are absent but history exists', () => {
55
57
  const current: OpenCostApplicationCostResponse = {
56
58
  available: false,
59
+ currency: 'USD',
57
60
  reason: 'no_metrics',
58
61
  totals: {
59
62
  hourlyCost: 0,
@@ -69,6 +72,7 @@ describe('getApplicationCostState', () => {
69
72
  }
70
73
  const trend: OpenCostApplicationCostTrendResponse = {
71
74
  available: true,
75
+ currency: 'USD',
72
76
  range: '7d',
73
77
  windowTotalCost: 2,
74
78
  dataPoints: [
@@ -89,6 +93,7 @@ describe('getApplicationCostState', () => {
89
93
  it('treats all tracked workloads scaled to zero as valid zero state', () => {
90
94
  const current: OpenCostApplicationCostResponse = {
91
95
  available: true,
96
+ currency: 'USD',
92
97
  totals: {
93
98
  hourlyCost: 0,
94
99
  cpuCost: 0,
@@ -168,6 +173,7 @@ describe('getApplicationCostState', () => {
168
173
  it('surfaces app workload access and existence failures', () => {
169
174
  const current: OpenCostApplicationCostResponse = {
170
175
  available: false,
176
+ currency: 'USD',
171
177
  reason: 'access_denied',
172
178
  totals: {
173
179
  hourlyCost: 0,
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
- import { AlertCircle, DollarSign, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
2
+ import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
3
3
  import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui'
4
4
  import {
5
5
  COST_DISCOVERY_GRACE_MS,
@@ -15,6 +15,7 @@ import {
15
15
  import { Tooltip } from '../ui/Tooltip'
16
16
  import { ChartLegend, CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
17
17
  import {
18
+ DEFAULT_COST_CURRENCY,
18
19
  formatCostPerHour,
19
20
  formatHistoricalSpend,
20
21
  formatProjectedDailyRate,
@@ -148,6 +149,8 @@ export function ApplicationCostTab({
148
149
  const hasTrend = points.length >= 2 && points.some((p) => p.value > 0)
149
150
  const rows = current?.workloads ?? []
150
151
  const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
152
+ const currentCurrency = current?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
153
+ const trendCurrency = trend?.currency ?? current?.currency ?? DEFAULT_COST_CURRENCY
151
154
 
152
155
  return (
153
156
  <div className="mx-auto w-full max-w-[1600px] space-y-4">
@@ -182,11 +185,11 @@ export function ApplicationCostTab({
182
185
  <div className="text-sm font-semibold text-theme-text-primary">
183
186
  Application compute cost
184
187
  </div>
185
- <CostInfoTooltip content="Dollars are based on OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
188
+ <CostInfoTooltip content="Values are based on OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
186
189
  </div>
187
190
  <div className="text-xs text-theme-text-tertiary">
188
- OpenCost CPU and memory allocation rate ($/hr) for Deployment, StatefulSet, and
189
- DaemonSet workloads
191
+ OpenCost CPU and memory allocation rate ({trendCurrency}/hr) for Deployment,
192
+ StatefulSet, and DaemonSet workloads
190
193
  </div>
191
194
  </div>
192
195
  </div>
@@ -201,6 +204,7 @@ export function ApplicationCostTab({
201
204
  points.length,
202
205
  trend?.windowTotalCost ?? 0,
203
206
  trendLoading || state === 'partial_missing_history',
207
+ trendCurrency,
204
208
  )}
205
209
  subvalue={
206
210
  state === 'partial_missing_history'
@@ -210,10 +214,10 @@ export function ApplicationCostTab({
210
214
  />
211
215
  <CostMetricBlock
212
216
  label="Projected monthly"
213
- value={totals ? formatProjectedMonthlyCost(hourly) : '—'}
217
+ value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
214
218
  subvalue={
215
219
  totals
216
- ? `${formatCostPerHour(hourly)} current rate`
220
+ ? `${formatCostPerHour(hourly, currentCurrency)} current rate`
217
221
  : 'Current allocation unavailable'
218
222
  }
219
223
  />
@@ -226,7 +230,7 @@ export function ApplicationCostTab({
226
230
  </div>
227
231
  ) : hasTrend && chartSeries.length > 0 ? (
228
232
  <div className="min-w-0">
229
- <StackedAreaChart series={chartSeries} />
233
+ <StackedAreaChart series={chartSeries} currency={trendCurrency} />
230
234
  <ChartLegend series={chartSeries} />
231
235
  </div>
232
236
  ) : (
@@ -250,16 +254,17 @@ export function ApplicationCostTab({
250
254
  />
251
255
  <CostMetricTile
252
256
  label="Projected daily"
253
- value={totals ? formatProjectedDailyRate(hourly) : '—'}
257
+ value={totals ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
254
258
  subvalue={
255
259
  totals
256
- ? `${formatCostPerHour(hourly)} current hourly rate`
260
+ ? `${formatCostPerHour(hourly, currentCurrency)} current hourly rate`
257
261
  : 'Current allocation unavailable'
258
262
  }
259
263
  />
260
264
  </div>
261
265
 
262
266
  <CurrentAllocationUse
267
+ currency={currentCurrency}
263
268
  dataAvailable={Boolean(totals)}
264
269
  cpuCost={totals?.cpuCost ?? 0}
265
270
  memoryCost={totals?.memoryCost ?? 0}
@@ -296,6 +301,7 @@ export function ApplicationCostTab({
296
301
  key={applicationCostKey(row)}
297
302
  row={row}
298
303
  maxCost={maxCost}
304
+ currency={currentCurrency}
299
305
  onOpen={
300
306
  appWorkload && onSelectWorkloadCost
301
307
  ? () => onSelectWorkloadCost(appWorkload)
@@ -309,9 +315,13 @@ export function ApplicationCostTab({
309
315
  </section>
310
316
 
311
317
  <div className="text-xs text-theme-text-tertiary">
312
- Powered by OpenCost via Prometheus. Historical spend uses the selected range; projected
313
- monthly values multiply current hourly allocation. Batch/job cost is separate; storage/PVC
314
- and network costs remain at namespace and cluster level.
318
+ Powered by OpenCost via Prometheus.{' '}
319
+ {currentCurrency !== DEFAULT_COST_CURRENCY && (
320
+ <>Labeled {currentCurrency}; no conversion. </>
321
+ )}
322
+ Historical spend uses the selected range; projected monthly values multiply current hourly
323
+ allocation. Batch/job cost is separate; storage/PVC and network costs remain at namespace
324
+ and cluster level.
315
325
  </div>
316
326
  </div>
317
327
  )
@@ -361,10 +371,12 @@ export function applicationCostWorkloads(workloads: AppWorkload[]): AppWorkload[
361
371
  function ApplicationWorkloadCostRow({
362
372
  row,
363
373
  maxCost,
374
+ currency,
364
375
  onOpen,
365
376
  }: {
366
377
  row: OpenCostApplicationWorkloadCost
367
378
  maxCost: number
379
+ currency: string
368
380
  onOpen?: () => void
369
381
  }) {
370
382
  const current = row.current
@@ -392,10 +404,10 @@ function ApplicationWorkloadCostRow({
392
404
  )}
393
405
  </div>
394
406
  <div className="text-right text-sm font-medium tabular-nums text-theme-text-primary">
395
- {current ? formatProjectedMonthlyRate(hourly) : '—'}
407
+ {current ? formatProjectedMonthlyRate(hourly, currency) : '—'}
396
408
  </div>
397
409
  <div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary sm:block">
398
- {current ? formatCostPerHour(hourly) : '—'}
410
+ {current ? formatCostPerHour(hourly, currency) : '—'}
399
411
  </div>
400
412
  <div className="hidden min-w-0 items-center gap-2 md:flex">
401
413
  <div
@@ -410,7 +422,7 @@ function ApplicationWorkloadCostRow({
410
422
  </div>
411
423
  <div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary lg:block">
412
424
  {current
413
- ? `${formatProjectedMonthlyCost(current.cpuCost)} / ${formatProjectedMonthlyCost(current.memoryCost)}`
425
+ ? `${formatProjectedMonthlyCost(current.cpuCost, currency)} / ${formatProjectedMonthlyCost(current.memoryCost, currency)}`
414
426
  : '—'}
415
427
  </div>
416
428
  </>
@@ -512,7 +524,7 @@ function ApplicationCostUnavailable({
512
524
  return (
513
525
  <div className="flex h-full min-h-[320px] items-center justify-center">
514
526
  <div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
515
- <DollarSign className="h-8 w-8 text-theme-text-tertiary/50" />
527
+ <Coins className="h-8 w-8 text-theme-text-tertiary/50" />
516
528
  <div className="text-sm">{text}</div>
517
529
  </div>
518
530
  </div>
@@ -2,7 +2,7 @@ import { useState, useMemo, useRef, useCallback } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Loader2, TrendingUp } from 'lucide-react'
4
4
  import { useOpenCostTrend, type CostTimeRange, type OpenCostTrendSeries } from '../../api/client'
5
- import { formatCostAxis, formatCostPerHour } from './format'
5
+ import { DEFAULT_COST_CURRENCY, formatCostAxis, formatCostPerHour } from './format'
6
6
 
7
7
  const SERIES_COLORS = [
8
8
  '#3b82f6', // blue-500
@@ -41,6 +41,8 @@ export function CostTrendChart() {
41
41
  return null
42
42
  }
43
43
 
44
+ const currency = data.currency ?? DEFAULT_COST_CURRENCY
45
+
44
46
  return (
45
47
  <div className="rounded-lg border border-theme-border bg-theme-surface/50">
46
48
  <div className="flex items-center justify-between px-4 py-2.5 border-b border-theme-border">
@@ -48,20 +50,28 @@ export function CostTrendChart() {
48
50
  <TrendingUp className="w-4 h-4 text-theme-text-tertiary" />
49
51
  <div>
50
52
  <div className="text-xs font-medium text-theme-text-secondary">Cost rate trend</div>
51
- <div className="text-[10px] text-theme-text-tertiary">Historical OpenCost allocation rate ($/hr)</div>
53
+ <div className="text-[10px] text-theme-text-tertiary">
54
+ Historical OpenCost allocation rate ({currency}/hr)
55
+ </div>
52
56
  </div>
53
57
  </div>
54
58
  <CostTimeRangeSelector value={timeRange} onChange={setTimeRange} />
55
59
  </div>
56
60
  <div className="p-4">
57
- <StackedAreaChart series={data.series} />
61
+ <StackedAreaChart series={data.series} currency={currency} />
58
62
  <ChartLegend series={data.series} />
59
63
  </div>
60
64
  </div>
61
65
  )
62
66
  }
63
67
 
64
- export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
68
+ export function StackedAreaChart({
69
+ series,
70
+ currency,
71
+ }: {
72
+ series: OpenCostTrendSeries[]
73
+ currency: string
74
+ }) {
65
75
  const svgRef = useRef<SVGSVGElement>(null)
66
76
  const [hoverX, setHoverX] = useState<number | null>(null)
67
77
 
@@ -123,7 +133,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
123
133
  const tickCount = 4
124
134
  const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
125
135
  const val = (yMax / tickCount) * i
126
- return { val, y: toY(val), label: formatCostAxis(val) }
136
+ return { val, y: toY(val), label: formatCostAxis(val, currency) }
127
137
  })
128
138
 
129
139
  // X axis ticks
@@ -175,7 +185,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
175
185
  xTicks,
176
186
  paths,
177
187
  }
178
- }, [series, plotHeight, plotWidth])
188
+ }, [series, currency, plotHeight, plotWidth])
179
189
 
180
190
  // Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
181
191
  const hoverData = useMemo(() => {
@@ -341,14 +351,14 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
341
351
  <div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: p.color }} />
342
352
  <span className="text-theme-text-secondary">{p.namespace}</span>
343
353
  <span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
344
- {formatCostTooltip(p.value)}
354
+ {formatCostTooltip(p.value, currency)}
345
355
  </span>
346
356
  </div>
347
357
  ))}
348
358
  {series.length > 1 && (
349
359
  <div className="border-t border-theme-border/50 mt-1 pt-1 flex justify-between text-theme-text-primary font-semibold">
350
360
  <span>Total</span>
351
- <span className="tabular-nums">{formatCostTooltip(hoverData.total)}</span>
361
+ <span className="tabular-nums">{formatCostTooltip(hoverData.total, currency)}</span>
352
362
  </div>
353
363
  )}
354
364
  </div>
@@ -403,8 +413,8 @@ export function CostTimeRangeSelector({
403
413
  )
404
414
  }
405
415
 
406
- function formatCostTooltip(value: number): string {
407
- return formatCostPerHour(value)
416
+ function formatCostTooltip(value: number, currency: string): string {
417
+ return formatCostPerHour(value, currency)
408
418
  }
409
419
 
410
420
  function formatTimestamp(unix: number): string {