@skyhook-io/radar-app 1.11.0 → 1.12.3

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 (67) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +37 -17
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +244 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +74 -1
  10. package/src/components/ConnectionErrorView.tsx +22 -20
  11. package/src/components/ContextSwitcher.tsx +10 -13
  12. package/src/components/applications/ApplicationsView.tsx +10 -9
  13. package/src/components/audit/AuditView.tsx +6 -3
  14. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  15. package/src/components/audit/UpgradeReadinessView.tsx +22 -12
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  17. package/src/components/capacity/schedulingBar.test.ts +10 -0
  18. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  19. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  20. package/src/components/cost/CostTrendChart.tsx +20 -10
  21. package/src/components/cost/CostView.tsx +79 -28
  22. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  23. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  24. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  25. package/src/components/cost/format.test.ts +27 -8
  26. package/src/components/cost/format.ts +78 -27
  27. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  28. package/src/components/gitops/GitOpsView.tsx +8 -3
  29. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  30. package/src/components/helm/OwnedResources.tsx +10 -2
  31. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  32. package/src/components/home/ClusterHealthCard.tsx +63 -1
  33. package/src/components/home/CostCard.tsx +12 -7
  34. package/src/components/home/HomeView.tsx +36 -11
  35. package/src/components/home/MCPSetupDialog.tsx +5 -4
  36. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  37. package/src/components/home/RadarVersionLine.tsx +137 -0
  38. package/src/components/home/mcpToolCatalog.ts +12 -0
  39. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  40. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  41. package/src/components/resources/ResourcesView.tsx +31 -8
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +181 -40
  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/ui/DiagnosticsOverlay.test.ts +40 -0
  48. package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
  49. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  50. package/src/components/ui/ErrorBoundary.tsx +17 -2
  51. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  52. package/src/components/ui/UpdateNotification.tsx +6 -2
  53. package/src/components/ui/command-items.ts +4 -14
  54. package/src/components/workload/WorkloadView.test.ts +60 -0
  55. package/src/components/workload/WorkloadView.tsx +272 -30
  56. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  57. package/src/contexts/CapabilitiesContext.tsx +7 -3
  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.test.ts +45 -0
  62. package/src/utils/navigation.ts +5 -5
  63. package/src/utils/topology-selection.ts +3 -2
  64. package/src/utils/version.test.ts +37 -0
  65. package/src/utils/version.ts +56 -0
  66. package/src/utils/wails-clipboard.test.ts +109 -0
  67. package/src/utils/wails-clipboard.ts +127 -0
@@ -1,15 +1,25 @@
1
1
  import { renderToStaticMarkup } from 'react-dom/server'
2
2
  import type { ReactNode } from 'react'
3
- import { describe, expect, it, vi } from 'vitest'
3
+ import { beforeEach, 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
+ const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
12
+
7
13
  vi.mock('@skyhook-io/k8s-ui', () => ({
8
14
  ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
9
15
  useOpenLocalTerminal: () => vi.fn(),
10
16
  }))
11
17
  vi.mock('../api/client', () => ({
12
18
  useAuthMe: () => ({ data: { authEnabled: false } }),
19
+ useContexts: useContextsMock,
20
+ }))
21
+ vi.mock('../contexts/CapabilitiesContext', () => ({
22
+ useCapabilitiesContext: () => capabilitiesMock,
13
23
  }))
14
24
  vi.mock('./ContextSwitcher', () => ({
15
25
  ContextSwitcher: () => <button>Switch context</button>,
@@ -35,6 +45,10 @@ function renderError(errorType: string, context: string): string {
35
45
  )
36
46
  }
37
47
 
48
+ beforeEach(() => {
49
+ capabilitiesMock.localTerminal = true
50
+ })
51
+
38
52
  describe('ConnectionErrorView authentication guidance', () => {
39
53
  it('builds an honest EKS diagnostic without presenting it as authentication', () => {
40
54
  const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
@@ -72,6 +86,34 @@ describe('ConnectionErrorView authentication guidance', () => {
72
86
  expect(markup).not.toContain('aws sso login')
73
87
  })
74
88
 
89
+ it('uses the original context for collision-qualified auth guidance', () => {
90
+ const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
91
+ const hints = selectConnectionHints('auth', `${original} (secondary)`, original)
92
+
93
+ expect(hints?.title).toBe('EKS Authentication Failed')
94
+ expect(hints?.fallbackCommand?.command).toBe(
95
+ 'aws eks update-kubeconfig --name prod --region us-east-1',
96
+ )
97
+ })
98
+
99
+ it('wires the current context original name into auth guidance', () => {
100
+ const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
101
+ useContextsMock.mockReturnValueOnce({
102
+ data: [{
103
+ name: `${original} (secondary)`,
104
+ originalName: original,
105
+ cluster: original,
106
+ user: 'prod',
107
+ namespace: '',
108
+ isCurrent: true,
109
+ }],
110
+ })
111
+
112
+ const markup = renderError('auth', `${original} (secondary)`)
113
+ expect(markup).toContain('update-kubeconfig')
114
+ expect(markup).toContain('us-east-1')
115
+ })
116
+
75
117
  it('renders placeholder AKS commands without a run affordance', () => {
76
118
  const markup = renderError('auth-rejected', 'clusterUser_platform_prod')
77
119
 
@@ -85,4 +127,35 @@ describe('ConnectionErrorView authentication guidance', () => {
85
127
 
86
128
  expect(markup).not.toContain('aria-label="Run command in terminal"')
87
129
  })
130
+
131
+ it('keeps recovery commands copyable without offering an unavailable local terminal', () => {
132
+ capabilitiesMock.localTerminal = false
133
+
134
+ const markup = renderError('auth', 'gke_project_us-east1_prod')
135
+
136
+ expect(markup).toContain('Refresh Google Cloud credentials')
137
+ expect(markup).toContain('>gcloud</span>')
138
+ expect(markup).toContain('aria-label="Copy command to clipboard"')
139
+ expect(markup).not.toContain('aria-label="Run command in terminal"')
140
+ expect(markup).not.toContain('Authenticate in terminal')
141
+ })
142
+ })
143
+
144
+ describe('ConnectionErrorView kubeconfig guidance', () => {
145
+ it('keeps the actionable error and context switch visible for a broken context', () => {
146
+ const markup = renderError('config', 'prod')
147
+
148
+ expect(markup).toContain('Cannot Load Cluster Configuration')
149
+ expect(markup).toContain('Kubeconfig Problem')
150
+ expect(markup).toContain('aria-expanded="false"')
151
+ expect(markup).toContain('id="connection-raw-error"')
152
+ expect(markup).toContain('safe error')
153
+ expect(markup).toContain('Switch context')
154
+ })
155
+
156
+ it('does not offer context switching when no context was loaded', () => {
157
+ const markup = renderError('config', '')
158
+
159
+ expect(markup).not.toContain('Switch context')
160
+ })
88
161
  })
@@ -4,10 +4,11 @@ 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'
11
+ import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
11
12
 
12
13
  interface ConnectionErrorViewProps {
13
14
  connection: ConnectionState
@@ -218,11 +219,11 @@ function getTimeoutHints(context: string): AuthHints | null {
218
219
 
219
220
  const errorHints: Record<string, { title: string; hints: string[] }> = {
220
221
  config: {
221
- title: 'No Kubeconfig Found',
222
+ title: 'Kubeconfig Problem',
222
223
  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',
224
+ 'Radar could not load a usable kubeconfig for this context',
225
+ 'If the file exists, check the local Radar logs for the exact parse or load failure',
226
+ 'If no kubeconfig is configured, Radar checks ~/.kube/config; set KUBECONFIG or pass --kubeconfig <path> for another location',
226
227
  ],
227
228
  },
228
229
  rbac: {
@@ -321,16 +322,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
321
322
  )
322
323
  }
323
324
 
324
- export function selectConnectionHints(errorType: string | undefined, context: string): AuthHints | null {
325
+ export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
326
+ const parsedContext = originalContext || context
325
327
  switch (errorType) {
326
328
  case 'auth':
327
- return getAuthHints(context)
329
+ return getAuthHints(parsedContext)
328
330
  case 'auth-rejected':
329
- return getAuthRejectedHints(context)
331
+ return getAuthRejectedHints(parsedContext)
330
332
  case 'auth-plugin-stuck':
331
333
  return getAuthPluginStuckHints()
332
334
  case 'timeout':
333
- return getTimeoutHints(context)
335
+ return getTimeoutHints(parsedContext)
334
336
  default:
335
337
  return null
336
338
  }
@@ -342,10 +344,13 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
342
344
  const isAuthRejected = connection.errorType === 'auth-rejected'
343
345
  const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
344
346
  const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
345
- const commandInfo = selectConnectionHints(connection.errorType, connection.context || '')
347
+ const { data: contexts } = useContexts()
348
+ const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
349
+ const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
346
350
  const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
347
351
  const openLocalTerminal = useOpenLocalTerminal()
348
352
  const { data: authMe } = useAuthMe()
353
+ const { localTerminal } = useCapabilitiesContext()
349
354
  const rawErrorDefaultOpen = !connection.errorType || connection.errorType === 'unknown'
350
355
  const [showRawError, setShowRawError] = useState(rawErrorDefaultOpen)
351
356
 
@@ -353,11 +358,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
353
358
  setShowRawError(rawErrorDefaultOpen)
354
359
  }, [connection.error, rawErrorDefaultOpen])
355
360
 
356
- // Auto-retry after successful auth. The terminal shell runs on the server
357
- // host, so the auth command itself fixes the server's credentials in every
358
- // mode — but the chained retry curl carries no session cookie, so it 401s
359
- // once /api/connection is auth-gated. Only chain it when auth is *known*
360
- // disabled (authMe still loading → don't chain a doomed call).
361
+ // The local terminal is only available in unauthenticated local mode, but
362
+ // authMe may still be loading when the capability response arrives.
361
363
  const retryCmd = `curl -s -X POST http://${window.location.host}${apiUrl('/connection/retry')} > /dev/null`
362
364
 
363
365
  const handleAuthInTerminal = () => {
@@ -384,7 +386,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
384
386
  </div>
385
387
 
386
388
  <h2 className="text-xl font-semibold text-theme-text-primary mb-2">
387
- {connection.errorType === 'config' ? 'No Cluster Configuration' : 'Cannot Connect to Cluster'}
389
+ {connection.errorType === 'config' ? 'Cannot Load Cluster Configuration' : 'Cannot Connect to Cluster'}
388
390
  </h2>
389
391
 
390
392
  <div className="mb-6 space-y-1">
@@ -418,8 +420,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
418
420
  {commandInfo?.authCommand && (
419
421
  <div className="mt-3">
420
422
  <p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
421
- <CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
422
- {isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
423
+ <CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={!localTerminal || commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
424
+ {localTerminal && isAuthError && !commandInfo?.hideAuthButton && commandInfo.authCommand.runnable !== false && (
423
425
  <button
424
426
  onClick={handleAuthInTerminal}
425
427
  className="mt-3 w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium btn-brand rounded-md"
@@ -433,7 +435,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
433
435
  {commandInfo?.fallbackCommand && (
434
436
  <div className="mt-4 pt-3 border-t border-theme-border/50">
435
437
  <p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
436
- <CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
438
+ <CopyableCommand command={commandInfo.fallbackCommand.command} onRunInTerminal={!localTerminal || commandInfo.fallbackCommand.runnable === false ? undefined : handleRunInTerminal} />
437
439
  </div>
438
440
  )}
439
441
  {connection.error && (
@@ -480,7 +482,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
480
482
  )}
481
483
  </button>
482
484
 
483
- {connection.errorType !== 'config' && <ContextSwitcher triggerName="Switch context" />}
485
+ {connection.context && <ContextSwitcher triggerName="Switch context" />}
484
486
  </div>
485
487
 
486
488
  {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}
@@ -22,6 +22,7 @@ import {
22
22
  eventsForApplication,
23
23
  memberRef,
24
24
  subjectRef,
25
+ compareIssueSortAnchors,
25
26
  type AppRow,
26
27
  type AppWorkload,
27
28
  type AppIdentityInstance,
@@ -46,7 +47,7 @@ import {
46
47
  } from "../../api/client";
47
48
  import { useConnection } from "../../context/ConnectionContext";
48
49
  import { useTimelineSource } from "../../context/TimelineSource";
49
- import { buildWorkloadPath, kindToPlural } from "../../utils/navigation";
50
+ import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
50
51
  import { WorkloadView } from "../workload/WorkloadView";
51
52
  import { ApplicationCostTab } from "../cost/ApplicationCostTab";
52
53
  import { isOpenCostWorkloadKind } from "../cost/kinds";
@@ -457,7 +458,7 @@ function AppDetailRoute({
457
458
  params.set("workload", workloadKey(workload));
458
459
  params.set(
459
460
  "run",
460
- `${kindToPlural(run.kind)}/${runNamespace}/${run.name}`,
461
+ `${kindToPluralWithGroup(run.kind, apiVersionToGroup(run.data?.apiVersion as string | undefined))}/${runNamespace}/${run.name}`,
461
462
  );
462
463
  setSearchParams(params);
463
464
  },
@@ -465,14 +466,15 @@ function AppDetailRoute({
465
466
  );
466
467
  const openWorkloadResource = useCallback(
467
468
  (resource: SelectedResource) => {
468
- if (kindToPlural(resource.kind).toLowerCase() !== "pods") {
469
+ const pluralKind = kindToPluralWithGroup(resource.kind, resource.group ?? "")
470
+ if (pluralKind.toLowerCase() !== "pods") {
469
471
  onOpenResource(resource);
470
472
  return;
471
473
  }
472
474
 
473
475
  const [pathname, rawSearch = ""] = buildWorkloadPath({
474
476
  ...resource,
475
- kind: kindToPlural(resource.kind),
477
+ kind: pluralKind,
476
478
  }).split("?");
477
479
  const params = new URLSearchParams(rawSearch);
478
480
  const activeNamespaces = searchParams.get("namespaces");
@@ -682,7 +684,7 @@ function AppDetailRoute({
682
684
  renderWorkload={(workload: SelectedAppWorkload) => (
683
685
  <div className="h-full overflow-hidden">
684
686
  <WorkloadView
685
- kind={kindToPlural(workload.kind)}
687
+ kind={kindToPluralWithGroup(workload.kind, workload.group ?? "")}
686
688
  group={workload.group}
687
689
  namespace={workload.namespace}
688
690
  name={workload.name}
@@ -837,7 +839,7 @@ function AppOverviewIssueRows({
837
839
  }) {
838
840
  const navigate = (ref: IssueResourceRef) => {
839
841
  onOpenResource({
840
- kind: kindToPlural(ref.kind),
842
+ kind: kindToPluralWithGroup(ref.kind, ref.group ?? ""),
841
843
  namespace: ref.namespace ?? "",
842
844
  name: ref.name,
843
845
  group: ref.group ?? "",
@@ -888,9 +890,8 @@ function compareAppOverviewIssues(a: Issue, b: Issue): number {
888
890
  const severity =
889
891
  ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
890
892
  if (severity !== 0) return severity;
891
- const fa = a.first_seen ?? "";
892
- const fb = b.first_seen ?? "";
893
- if (fa !== fb) return fb.localeCompare(fa);
893
+ const onset = compareIssueSortAnchors(a, b);
894
+ if (onset !== 0) return onset;
894
895
  const ns = (a.namespace ?? "").localeCompare(b.namespace ?? "");
895
896
  if (ns !== 0) return ns;
896
897
  const name = a.name.localeCompare(b.name);
@@ -88,9 +88,14 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
88
88
 
89
89
  return (
90
90
  <div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
91
+ {/* Tabs above the header: the header describes best practices only, so
92
+ tabs rendered below it read as part of that page rather than as the
93
+ switch between the two Checks surfaces. */}
94
+ <ChecksViewTabs />
95
+
91
96
  <PageHeader
92
97
  icon={ShieldCheck}
93
- title="Checks"
98
+ title="Best practices"
94
99
  description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
95
100
  actions={
96
101
  <>
@@ -115,8 +120,6 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
115
120
  }
116
121
  />
117
122
 
118
- <ChecksViewTabs />
119
-
120
123
  <ChecksView
121
124
  checks={data.groupedChecks ?? []}
122
125
  catalog={data.checks ?? {}}
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { ApiError } from '../../api/client'
4
- import { UPGRADE_IMPACT_DOCS_URL, UPGRADE_IMPACT_MIN_RADAR_VERSION, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary } from './UpgradeReadinessView'
4
+ import { UPGRADE_IMPACT_DOCS_URL, UPGRADE_IMPACT_MIN_RADAR_VERSION, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary, upgradeEvidenceCoverageLabel, upgradeUnavailableKindsMessage } from './UpgradeReadinessView'
5
5
 
6
6
  describe('UpgradeReadinessError', () => {
7
7
  it('maps an unmatched endpoint 404 to the v1.9 upgrade message', () => {
@@ -40,7 +40,7 @@ describe('upgradeEvaluationSummary', () => {
40
40
  unknown: 1,
41
41
  notApplicable: 2,
42
42
  findings: 1,
43
- })).toBe('18 evaluated · 16 applicable · 1 with partial evidence · 2 not applicable')
43
+ })).toBe('18 evaluated · 16 applicable · 1 with incomplete evidence · 2 not applicable')
44
44
  })
45
45
 
46
46
  it('does not imply incomplete coverage when every applicable check completed', () => {
@@ -68,6 +68,30 @@ describe('upgradeEvaluationSummary', () => {
68
68
  })
69
69
  })
70
70
 
71
+ describe('upgradeUnavailableKindsMessage', () => {
72
+ it('does not guess why webhook evidence is unavailable', () => {
73
+ const message = upgradeUnavailableKindsMessage(['mutatingwebhookconfigurations', 'validatingwebhookconfigurations'])
74
+
75
+ expect(message).toContain('Affected checks are marked incomplete')
76
+ expect(message).not.toContain('rbac.viewWebhooks')
77
+ expect(message).not.toContain('grant')
78
+ })
79
+
80
+ it('does not suggest webhook access for unrelated evidence gaps', () => {
81
+ expect(upgradeUnavailableKindsMessage(['apiservices'])).not.toContain('rbac.viewWebhooks')
82
+ })
83
+ })
84
+
85
+ describe('upgradeEvidenceCoverageLabel', () => {
86
+ it('distinguishes absent evidence from partial evidence', () => {
87
+ expect(upgradeEvidenceCoverageLabel({ status: 'unknown', caveat: 'unavailable' })).toBe('No evidence')
88
+ expect(upgradeEvidenceCoverageLabel({ status: 'unknown', inspected: 0, caveat: 'unavailable' })).toBe('No evidence')
89
+ expect(upgradeEvidenceCoverageLabel({ status: 'unknown', inspected: 1, caveat: 'one object malformed' })).toBe('Partial evidence')
90
+ expect(upgradeEvidenceCoverageLabel({ status: 'blocked', inspected: 1, caveat: 'some nodes unavailable' })).toBe('Partial evidence')
91
+ expect(upgradeEvidenceCoverageLabel({ status: 'passed', inspected: 1 })).toBe('')
92
+ })
93
+ })
94
+
71
95
  describe('groupFindings', () => {
72
96
  it('groups repeated remediation inside a check without merging distinct action levels', () => {
73
97
  const base = {
@@ -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),
@@ -127,6 +127,8 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
127
127
 
128
128
  return (
129
129
  <div aria-busy={isFetching} className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
130
+ <ChecksViewTabs />
131
+
130
132
  <PageHeader
131
133
  icon={ShieldAlert}
132
134
  title="Upgrade impact"
@@ -153,8 +155,8 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
153
155
  )}
154
156
  <FreshnessControl
155
157
  mode="snapshot"
156
- dataUpdatedAt={dataUpdatedAt}
157
- onRefresh={() => refetch()}
158
+ dataUpdatedAt={data.observedAt ? Date.parse(data.observedAt) : dataUpdatedAt}
159
+ onRefresh={() => refreshScan()}
158
160
  connectionState={connection.state}
159
161
  isFetching={isFetching}
160
162
  />
@@ -162,8 +164,6 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
162
164
  }
163
165
  />
164
166
 
165
- <ChecksViewTabs />
166
-
167
167
  {showingPreviousTarget && (
168
168
  <CoverageNotice
169
169
  headline={`Analyzing Kubernetes ${requestedTarget}`}
@@ -202,7 +202,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
202
202
  {data.coverage.state !== 'no_access' && (data.coverage.unavailableKinds?.length ?? 0) > 0 && (
203
203
  <CoverageNotice
204
204
  headline="Some live resources were unavailable"
205
- body={`Radar could not inspect: ${(data.coverage.unavailableKinds ?? []).join(', ')}. Affected checks are marked incomplete.`}
205
+ body={upgradeUnavailableKindsMessage(data.coverage.unavailableKinds ?? [])}
206
206
  />
207
207
  )}
208
208
  {data.coverage.state !== 'no_access' && scopedKinds.length > 0 && (
@@ -212,7 +212,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
212
212
  />
213
213
  )}
214
214
  {data.coverage.state === 'partial' && !data.coverage.scopedNamespaces?.length && !data.coverage.unavailableKinds?.length && scopedKinds.length === 0 && (
215
- <CoverageNotice headline="Some evidence is incomplete" body="Rows marked Partial evidence explain what Radar could not verify." />
215
+ <CoverageNotice headline="Some evidence is incomplete" body="Evidence labels on affected rows explain what Radar could not verify." />
216
216
  )}
217
217
  {data.coverage.state === 'no_access' ? (
218
218
  <section className="shrink-0">
@@ -291,8 +291,17 @@ export function incompleteUpgradeCheckCount(checks: Pick<UpgradeReadinessCheck,
291
291
 
292
292
  export function upgradeEvaluationSummary(total: number, summary: UpgradeReadinessResponse['summary'], incomplete = summary.unknown) {
293
293
  const applicable = Math.max(0, total - summary.notApplicable)
294
- const partialEvidenceLabel = incomplete > 0 ? ` · ${incomplete} with partial evidence` : ''
295
- return `${total} evaluated · ${applicable} applicable${partialEvidenceLabel} · ${summary.notApplicable} not applicable`
294
+ const incompleteEvidenceLabel = incomplete > 0 ? ` · ${incomplete} with incomplete evidence` : ''
295
+ return `${total} evaluated · ${applicable} applicable${incompleteEvidenceLabel} · ${summary.notApplicable} not applicable`
296
+ }
297
+
298
+ export function upgradeUnavailableKindsMessage(unavailableKinds: string[]) {
299
+ return `Radar could not inspect: ${unavailableKinds.join(', ')}. Affected checks are marked incomplete.`
300
+ }
301
+
302
+ export function upgradeEvidenceCoverageLabel(check: Pick<UpgradeReadinessCheck, 'status' | 'inspected' | 'caveat'>) {
303
+ if (!check.caveat) return ''
304
+ return check.status === 'unknown' && (check.inspected ?? 0) === 0 ? 'No evidence' : 'Partial evidence'
296
305
  }
297
306
 
298
307
  function CoverageMethodology({ data }: { data: UpgradeReadinessResponse }) {
@@ -323,7 +332,7 @@ function CoverageMethodology({ data }: { data: UpgradeReadinessResponse }) {
323
332
  </p>
324
333
  <p>
325
334
  Results use live cluster resources and the row-specific evidence scope shown below. {unavailable.length > 0
326
- ? `Radar could not inspect ${unavailable.join(', ')}; affected checks are marked incomplete.`
335
+ ? upgradeUnavailableKindsMessage(unavailable)
327
336
  : scopedKinds.length > 0
328
337
  ? `Cached evidence has per-kind namespace ceilings for ${formatScopedKinds(scopedKinds)}.`
329
338
  : data.coverage.state === 'complete'
@@ -433,6 +442,7 @@ function CheckRow({ check, onNavigateToResource }: { check: UpgradeReadinessChec
433
442
  const detailID = `upgrade-check-${check.id}`
434
443
  const findingGroups = groupFindings(check.findings)
435
444
  const label = evidenceLabel(check)
445
+ const coverageLabel = upgradeEvidenceCoverageLabel(check)
436
446
  const checkReferences = check.references ?? []
437
447
  return (
438
448
  <div>
@@ -456,10 +466,10 @@ function CheckRow({ check, onNavigateToResource }: { check: UpgradeReadinessChec
456
466
  <div className="min-w-0">
457
467
  <Badge severity={meta.badgeSeverity}>{meta.label}</Badge>
458
468
  {label && <div className="mt-1 text-[11px] text-theme-text-tertiary">{label}</div>}
459
- {check.caveat && (
469
+ {coverageLabel && (
460
470
  <div className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
461
471
  <AlertTriangle className="h-3 w-3 shrink-0" />
462
- Partial evidence
472
+ {coverageLabel}
463
473
  </div>
464
474
  )}
465
475
  </div>
@@ -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,