@skyhook-io/radar-app 1.12.2 → 1.13.0

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 (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -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 +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. package/src/utils/version.ts +56 -0
@@ -1,6 +1,6 @@
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
4
  import type { ContextInfo } from '../types'
5
5
 
6
6
  vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
@@ -8,6 +8,7 @@ vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
8
8
  const useContextsMock = vi.hoisted(() => vi.fn(
9
9
  (): { data: ContextInfo[] | undefined } => ({ data: undefined }),
10
10
  ))
11
+ const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
11
12
 
12
13
  vi.mock('@skyhook-io/k8s-ui', () => ({
13
14
  ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
@@ -17,6 +18,9 @@ vi.mock('../api/client', () => ({
17
18
  useAuthMe: () => ({ data: { authEnabled: false } }),
18
19
  useContexts: useContextsMock,
19
20
  }))
21
+ vi.mock('../contexts/CapabilitiesContext', () => ({
22
+ useCapabilitiesContext: () => capabilitiesMock,
23
+ }))
20
24
  vi.mock('./ContextSwitcher', () => ({
21
25
  ContextSwitcher: () => <button>Switch context</button>,
22
26
  }))
@@ -41,6 +45,10 @@ function renderError(errorType: string, context: string): string {
41
45
  )
42
46
  }
43
47
 
48
+ beforeEach(() => {
49
+ capabilitiesMock.localTerminal = true
50
+ })
51
+
44
52
  describe('ConnectionErrorView authentication guidance', () => {
45
53
  it('builds an honest EKS diagnostic without presenting it as authentication', () => {
46
54
  const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
@@ -119,6 +127,18 @@ describe('ConnectionErrorView authentication guidance', () => {
119
127
 
120
128
  expect(markup).not.toContain('aria-label="Run command in terminal"')
121
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
+ })
122
142
  })
123
143
 
124
144
  describe('ConnectionErrorView kubeconfig guidance', () => {
@@ -8,6 +8,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
@@ -349,6 +350,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
349
350
  const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
350
351
  const openLocalTerminal = useOpenLocalTerminal()
351
352
  const { data: authMe } = useAuthMe()
353
+ const { localTerminal } = useCapabilitiesContext()
352
354
  const rawErrorDefaultOpen = !connection.errorType || connection.errorType === 'unknown'
353
355
  const [showRawError, setShowRawError] = useState(rawErrorDefaultOpen)
354
356
 
@@ -356,11 +358,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
356
358
  setShowRawError(rawErrorDefaultOpen)
357
359
  }, [connection.error, rawErrorDefaultOpen])
358
360
 
359
- // Auto-retry after successful auth. The terminal shell runs on the server
360
- // host, so the auth command itself fixes the server's credentials in every
361
- // mode — but the chained retry curl carries no session cookie, so it 401s
362
- // once /api/connection is auth-gated. Only chain it when auth is *known*
363
- // 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.
364
363
  const retryCmd = `curl -s -X POST http://${window.location.host}${apiUrl('/connection/retry')} > /dev/null`
365
364
 
366
365
  const handleAuthInTerminal = () => {
@@ -421,8 +420,8 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
421
420
  {commandInfo?.authCommand && (
422
421
  <div className="mt-3">
423
422
  <p className="text-xs text-theme-text-tertiary">{commandInfo.authCommand.label}</p>
424
- <CopyableCommand command={commandInfo.authCommand.command} onRunInTerminal={commandInfo.authCommand.runnable === false ? undefined : handleRunInTerminal} />
425
- {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 && (
426
425
  <button
427
426
  onClick={handleAuthInTerminal}
428
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"
@@ -436,7 +435,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
436
435
  {commandInfo?.fallbackCommand && (
437
436
  <div className="mt-4 pt-3 border-t border-theme-border/50">
438
437
  <p className="text-xs text-theme-text-tertiary">{commandInfo.fallbackCommand.label}</p>
439
- <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} />
440
439
  </div>
441
440
  )}
442
441
  {connection.error && (
@@ -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 = {
@@ -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"
@@ -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>
@@ -53,6 +53,48 @@ describe('getApplicationCostState', () => {
53
53
  ).toBe('partial_missing_history')
54
54
  })
55
55
 
56
+ it('keeps Kubecost application totals visible when history is unsupported', () => {
57
+ const current: OpenCostApplicationCostResponse = {
58
+ available: true,
59
+ source: 'kubecost',
60
+ currency: 'USD',
61
+ totals: {
62
+ hourlyCost: 0.4, cpuCost: 0.25, memoryCost: 0.15, replicas: 3,
63
+ cpuUsageAvailable: true, memoryUsageAvailable: true,
64
+ cpuAllocationUse: 40, memoryAllocationUse: 60,
65
+ },
66
+ coverage: { total: 1, included: 1 },
67
+ workloads: [],
68
+ }
69
+ const trend: OpenCostApplicationCostTrendResponse = {
70
+ available: false,
71
+ source: 'kubecost',
72
+ reason: 'history_unsupported',
73
+ currency: 'USD',
74
+ range: '24h',
75
+ coverage: { total: 1, included: 0 },
76
+ }
77
+
78
+ expect(getApplicationCostState(current, trend, {})).toBe('partial_missing_history')
79
+ })
80
+
81
+ it('does not let unsupported history mask current loading or errors', () => {
82
+ const trend: OpenCostApplicationCostTrendResponse = {
83
+ available: false,
84
+ source: 'kubecost',
85
+ reason: 'history_unsupported',
86
+ currency: 'USD',
87
+ range: '24h',
88
+ coverage: { total: 1, included: 0 },
89
+ }
90
+
91
+ expect(getApplicationCostState(undefined, trend, { currentLoading: true })).toBe('loading')
92
+ expect(getApplicationCostState(undefined, trend, { currentError: true })).toBe('load_error')
93
+ expect(
94
+ getApplicationCostState(undefined, trend, { currentError: new ApiError('denied', 403) }),
95
+ ).toBe('access_denied')
96
+ })
97
+
56
98
  it('uses historical data when current app metrics are absent but history exists', () => {
57
99
  const current: OpenCostApplicationCostResponse = {
58
100
  available: false,
@@ -206,5 +248,8 @@ describe('getApplicationCostState', () => {
206
248
  }),
207
249
  ).toBe('access_denied')
208
250
  expect(getApplicationCostState(current, undefined, { trendError: true })).toBe('access_denied')
251
+
252
+ current.reason = 'configuration_mismatch'
253
+ expect(getApplicationCostState(current, undefined, {})).toBe('configuration_mismatch')
209
254
  })
210
255
  })