@skyhook-io/radar-app 1.11.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 (35) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +3 -2
  3. package/src/api/client.ts +36 -6
  4. package/src/components/ConnectionErrorView.test.tsx +53 -0
  5. package/src/components/ConnectionErrorView.tsx +15 -12
  6. package/src/components/ContextSwitcher.tsx +10 -13
  7. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  8. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  9. package/src/components/capacity/schedulingBar.test.ts +10 -0
  10. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  11. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  12. package/src/components/cost/CostTrendChart.tsx +20 -10
  13. package/src/components/cost/CostView.tsx +79 -28
  14. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  15. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  16. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  17. package/src/components/cost/format.test.ts +27 -8
  18. package/src/components/cost/format.ts +78 -27
  19. package/src/components/home/ClusterHealthCard.tsx +3 -0
  20. package/src/components/home/CostCard.tsx +12 -7
  21. package/src/components/home/mcpToolCatalog.ts +12 -0
  22. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  23. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  24. package/src/components/settings/SettingsDialog.tsx +160 -36
  25. package/src/components/settings/currency-options.test.ts +49 -0
  26. package/src/components/settings/currency-options.ts +38 -0
  27. package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
  28. package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
  29. package/src/components/ui/command-items.ts +4 -14
  30. package/src/components/workload/WorkloadView.tsx +2 -1
  31. package/src/main.tsx +4 -114
  32. package/src/utils/context-name.test.ts +63 -0
  33. package/src/utils/context-name.ts +22 -0
  34. package/src/utils/wails-clipboard.test.ts +109 -0
  35. package/src/utils/wails-clipboard.ts +127 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.11.0",
3
+ "version": "1.12.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@fontsource-variable/dm-sans": "^5.2.8",
33
- "@fontsource/dm-mono": "^5.2.7",
33
+ "@fontsource/dm-mono": "^5.3.0",
34
34
  "@monaco-editor/react": "^4.7.0",
35
35
  "diff": "^9.0.0",
36
36
  "monaco-editor": "0.55.1",
@@ -41,7 +41,7 @@
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.13.0",
44
+ "@skyhook-io/k8s-ui": ">=1.14.0",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
@@ -62,7 +62,7 @@
62
62
  "@types/node": "^26.2.0",
63
63
  "@types/react": "^19.2.18",
64
64
  "@types/react-dom": "^19.2.3",
65
- "@vitejs/plugin-react": "^6.0.4",
65
+ "@vitejs/plugin-react": "^6.1.0",
66
66
  "@xyflow/react": "^12.11.2",
67
67
  "clsx": "^2.1.1",
68
68
  "elkjs": "^0.11.1",
@@ -70,8 +70,8 @@
70
70
  "eslint-plugin-react-hooks": "^7.1.1",
71
71
  "eslint-plugin-react-refresh": "^0.5.3",
72
72
  "globals": "^17.9.0",
73
- "lucide-react": "^1.30.0",
74
- "postcss": "^8.5.25",
73
+ "lucide-react": "^1.33.0",
74
+ "postcss": "^8.5.26",
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
77
77
  "react-dom": "^19.2.8",
@@ -79,7 +79,7 @@
79
79
  "tailwind-merge": "^3.6.0",
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
82
- "typescript-eslint": "^8.62.0",
82
+ "typescript-eslint": "^8.67.0",
83
83
  "vite": "^8.2.1"
84
84
  },
85
85
  "sideEffects": [
package/src/App.tsx CHANGED
@@ -1664,8 +1664,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1664
1664
  {/* `relative` makes this column the containing block for the absolute
1665
1665
  overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
1666
1666
  span the content area AFTER the rail rather than the full viewport
1667
- under it. `fixed` splashes (connecting/switching) are unaffected. */}
1668
- <div className="relative flex flex-col flex-1 min-w-0 h-full">
1667
+ under it. Horizontal clipping keeps translated-offscreen drawers from
1668
+ contributing document overflow. `fixed` splashes are unaffected. */}
1669
+ <div className="relative flex flex-col flex-1 min-w-0 h-full overflow-x-clip">
1669
1670
  {/* Header — suppressed in chromeless embed; the host owns the chrome.
1670
1671
  @container: the header's responsive layout keys off its OWN width (≈ viewport
1671
1672
  − nav rail), not the viewport, so it collapses gracefully on narrow windows.
package/src/api/client.ts CHANGED
@@ -481,6 +481,12 @@ export interface UpgradeReadinessResponse {
481
481
  currentVersion: string
482
482
  targetVersion: string
483
483
  reviewedThrough: string
484
+ // When the underlying scan ran. The server memoizes scans (~60s), so a
485
+ // response can be older than the fetch that returned it — surface this,
486
+ // not dataUpdatedAt, as the scan time.
487
+ observedAt: string
488
+ scanId: string
489
+ fromCache?: boolean
484
490
  verdict: UpgradeReadinessVerdict
485
491
  summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number; findings: number }
486
492
  checks: UpgradeReadinessCheck[]
@@ -590,15 +596,28 @@ export function useAudit(namespaces: string[] = []) {
590
596
  }
591
597
 
592
598
  export function useUpgradeReadiness(target?: string) {
593
- const params = new URLSearchParams()
594
- if (target) params.set('target', target)
595
- const query = params.toString()
596
- return useQuery<UpgradeReadinessResponse>({
599
+ // The server memoizes scans per caller (~60s). Ordinary and background
600
+ // refetches read that memo; only the explicit manual refresh passes
601
+ // refresh=true to force a fresh live scan (e.g. after fixing a finding).
602
+ const forceRefresh = useRef(false)
603
+ const query = useQuery<UpgradeReadinessResponse>({
597
604
  queryKey: ['upgrade-readiness', target ?? 'next'],
598
- queryFn: ({ signal }) => fetchJSON(`/upgrade-readiness${query ? `?${query}` : ''}`, signal),
605
+ queryFn: ({ signal }) => {
606
+ const params = new URLSearchParams()
607
+ if (target) params.set('target', target)
608
+ if (forceRefresh.current) params.set('refresh', 'true')
609
+ forceRefresh.current = false
610
+ const qs = params.toString()
611
+ return fetchJSON(`/upgrade-readiness${qs ? `?${qs}` : ''}`, signal)
612
+ },
599
613
  staleTime: 30000,
600
614
  placeholderData: (previous) => previous,
601
615
  })
616
+ const refreshScan = () => {
617
+ forceRefresh.current = true
618
+ return query.refetch()
619
+ }
620
+ return { ...query, refreshScan }
602
621
  }
603
622
 
604
623
  // Live cluster Issues — the grouped triage queue (radar's /api/issues =
@@ -937,6 +956,7 @@ export interface OpenCostWorkloadCost {
937
956
  export interface OpenCostWorkloadResponse {
938
957
  available: boolean;
939
958
  reason?: CostUnavailableReason;
959
+ currency?: string;
940
960
  namespace: string;
941
961
  workloads: OpenCostWorkloadCost[];
942
962
  }
@@ -964,6 +984,7 @@ export function useOpenCostWorkloads(
964
984
  export interface OpenCostWorkloadDetailResponse {
965
985
  available: boolean;
966
986
  reason?: CostUnavailableReason;
987
+ currency?: string;
967
988
  namespace: string;
968
989
  kind: string;
969
990
  name: string;
@@ -1008,6 +1029,7 @@ export interface OpenCostTrendSeries {
1008
1029
  export interface OpenCostTrendResponse {
1009
1030
  available: boolean;
1010
1031
  reason?: CostUnavailableReason;
1032
+ currency?: string;
1011
1033
  range: string;
1012
1034
  series?: OpenCostTrendSeries[];
1013
1035
  }
@@ -1029,6 +1051,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
1029
1051
  export interface OpenCostWorkloadTrendResponse {
1030
1052
  available: boolean;
1031
1053
  reason?: CostUnavailableReason;
1054
+ currency?: string;
1032
1055
  namespace: string;
1033
1056
  kind: string;
1034
1057
  name: string;
@@ -1101,6 +1124,7 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
1101
1124
  export interface OpenCostApplicationCostResponse {
1102
1125
  available: boolean;
1103
1126
  reason?: CostUnavailableReason;
1127
+ currency?: string;
1104
1128
  partial?: boolean;
1105
1129
  totals: OpenCostApplicationCostTotals;
1106
1130
  coverage: OpenCostApplicationCostCoverage;
@@ -1115,6 +1139,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
1115
1139
  export interface OpenCostApplicationCostTrendResponse {
1116
1140
  available: boolean;
1117
1141
  reason?: CostUnavailableReason;
1142
+ currency?: string;
1118
1143
  range: string;
1119
1144
  partial?: boolean;
1120
1145
  windowTotalCost?: number;
@@ -1206,6 +1231,7 @@ export interface OpenCostNodeCost {
1206
1231
  export interface OpenCostNodeResponse {
1207
1232
  available: boolean;
1208
1233
  reason?: CostUnavailableReason;
1234
+ currency?: string;
1209
1235
  nodes?: OpenCostNodeCost[];
1210
1236
  }
1211
1237
 
@@ -3104,6 +3130,7 @@ export interface ArgoStatus {
3104
3130
  configured: boolean;
3105
3131
  connected: boolean;
3106
3132
  address?: string;
3133
+ reason?: string;
3107
3134
  }
3108
3135
 
3109
3136
  export function useArgoStatus(enabled = true) {
@@ -6405,10 +6432,13 @@ export interface DiagnosticsSnapshot {
6405
6432
  errorType?: string;
6406
6433
  };
6407
6434
  kubeconfig?: {
6408
- mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir";
6435
+ mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir" | "multi-source";
6409
6436
  fileCount: number;
6437
+ directoryFileCount: number;
6410
6438
  contextCount: number;
6411
6439
  enrichedFromShell: boolean;
6440
+ kubeconfigEnvIgnored: boolean;
6441
+ kubeconfigEnvIgnoredReason: string;
6412
6442
  currentContextUsesExec: boolean;
6413
6443
  execPluginsPresent?: string[];
6414
6444
  execPluginsMissing?: string[];
@@ -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,