@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,4 +1,4 @@
1
- import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
1
+ import { createElement, useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
4
4
  import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
@@ -7,6 +7,9 @@ import type { FileNode } from '../../types'
7
7
  import { formatBytes } from '../../utils/format'
8
8
  import { downloadBlob, filterTree } from './file-browser-utils'
9
9
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
10
+ import { isDesktopApp } from '../../utils/desktop-download'
11
+ import { openFile, openFolder } from '../../utils/desktop-open-folder'
12
+ import { useToast } from '../ui/Toast'
10
13
  import { Tooltip } from '../ui/Tooltip'
11
14
 
12
15
  interface PodFilesystem {
@@ -35,6 +38,36 @@ async function fetchPodFiles(
35
38
  return response.json()
36
39
  }
37
40
 
41
+ /**
42
+ * Desktop only: has the backend write the pod file straight to disk. The browser
43
+ * route would hand the whole file to the webview only to have it hand every byte
44
+ * back to be saved, which is what puts a large file out of reach there.
45
+ * Returns the path it was saved to.
46
+ */
47
+ async function savePodFileToDisk(
48
+ namespace: string,
49
+ podName: string,
50
+ container: string,
51
+ filePath: string,
52
+ ): Promise<string> {
53
+ const params = new URLSearchParams()
54
+ params.set('container', container)
55
+ params.set('path', filePath)
56
+
57
+ const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/save?${params.toString()}`), {
58
+ method: 'POST',
59
+ credentials: getCredentialsMode(),
60
+ headers: getAuthHeaders(),
61
+ })
62
+ if (response.status === 204) throw new Error('cancelled')
63
+ if (!response.ok) {
64
+ const error = await response.json().catch(() => ({ error: 'Save failed' }))
65
+ throw new Error(error.error || `HTTP ${response.status}`)
66
+ }
67
+ const body = await response.json()
68
+ return body.path
69
+ }
70
+
38
71
  interface PodFilesystemModalProps {
39
72
  open: boolean
40
73
  onClose: () => void
@@ -309,6 +342,7 @@ interface PodFileTreeNodeProps {
309
342
 
310
343
  function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: PodFileTreeNodeProps) {
311
344
  const [downloading, setDownloading] = useState(false)
345
+ const { showSuccess, showError } = useToast()
312
346
  const isDir = node.type === 'dir'
313
347
  const isSymlink = node.type === 'symlink'
314
348
  const isDownloadable = !isDir // files and symlinks can be downloaded
@@ -319,6 +353,21 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
319
353
 
320
354
  setDownloading(true)
321
355
  try {
356
+ if (await isDesktopApp()) {
357
+ const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
358
+ showSuccess(
359
+ 'File saved',
360
+ savedPath,
361
+ {
362
+ label: 'Show in Finder',
363
+ icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
364
+ onClick: () => openFolder(savedPath),
365
+ },
366
+ () => openFile(savedPath),
367
+ )
368
+ return
369
+ }
370
+
322
371
  const params = new URLSearchParams()
323
372
  params.set('container', container)
324
373
  params.set('path', node.path)
@@ -335,7 +384,10 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
335
384
  const blob = await response.blob()
336
385
  await downloadBlob(blob, node.name)
337
386
  } catch (err) {
338
- console.error('Download failed:', err)
387
+ const message = err instanceof Error ? err.message : String(err)
388
+ if (message !== 'cancelled') {
389
+ showError(`Could not download ${node.name}`, message)
390
+ }
339
391
  } finally {
340
392
  setDownloading(false)
341
393
  }
@@ -16,10 +16,12 @@ import {
16
16
  ResourcesView as BaseResourcesView,
17
17
  CORE_RESOURCES,
18
18
  intersectWorkloadWrites,
19
+ hasCuratedColumns,
20
+ sanitizePrinterTable,
19
21
  } from '@skyhook-io/k8s-ui'
20
- import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
22
+ import type { Capabilities, PrinterTable, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
21
23
  import type { SelectedResource } from '../../types'
22
- import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
24
+ import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } from '../../utils/navigation'
23
25
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
24
26
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
25
27
 
@@ -229,12 +231,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
229
231
  // Fetch full data only for the selected kind
230
232
  const selectedKindQuery = useQuery({
231
233
  queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
232
- queryFn: async () => {
233
- if (!selectedKind) return []
234
+ queryFn: async (): Promise<{ items: any[]; printerTable: PrinterTable | null }> => {
235
+ if (!selectedKind) return { items: [], printerTable: null }
234
236
  const params = new URLSearchParams()
235
237
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
236
238
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
237
239
  if (selectedKindSummaryServed) params.set('include', 'summary')
240
+ // Only CRDs can declare printer columns, and a curated kind discards the
241
+ // result — so table mode is requested from exactly the kinds that can use
242
+ // it. Resolving a table costs the server a CRD read per request; doing
243
+ // that for a kind whose columns are hand-curated is pure waste.
244
+ const wantsTable = isSelectedCrd && !!selectedKind.group &&
245
+ !hasCuratedColumns(selectedKind.name, selectedKind.group)
246
+ if (wantsTable) params.set('table', '1')
238
247
  const startedAt = performance.now()
239
248
  debugNamespaceLog('resources:selected-kind-fetch-start', {
240
249
  kind: selectedKind.name,
@@ -258,7 +267,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
258
267
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
259
268
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
260
269
  }
261
- return res.json()
270
+ const body = await res.json()
271
+ // Both branches are current shapes, not a guess at a legacy one: a Radar
272
+ // backend that predates `table` ignores the parameter and answers with
273
+ // the bare array. @skyhook-io/radar-app is versioned independently of the
274
+ // backend it points at, so a consumer can pair a new frontend with an
275
+ // older Radar — and reading that array as a missing envelope would render
276
+ // every CRD list empty. Items and columns still come from one response,
277
+ // so a row can never render against another fetch's cells.
278
+ if (!wantsTable || Array.isArray(body)) return { items: body as any[], printerTable: null }
279
+ return {
280
+ items: Array.isArray(body?.items) ? body.items as any[] : [],
281
+ printerTable: sanitizePrinterTable(body),
282
+ }
262
283
  },
263
284
  enabled: !!selectedKind && !selectedKindQueryBlocked,
264
285
  staleTime: 30000,
@@ -275,13 +296,13 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
275
296
  return {
276
297
  resourceName: selectedKind.name,
277
298
  group: selectedKind.group,
278
- data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
299
+ data: selectedKindQueryBlocked ? [] : selectedKindQuery.data?.items,
279
300
  isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
280
301
  error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
281
302
  refetch: selectedKindQuery.refetch,
282
303
  dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
283
304
  }
284
- }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
305
+ }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data?.items, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
285
306
 
286
307
  // Metrics
287
308
  const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
@@ -352,6 +373,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
352
373
  resourceReasons={countsData?.reasons}
353
374
  resourceUnavailable={countsData?.unavailable}
354
375
  selectedKindQuery={selectedKindQueryResult}
376
+ printerTable={selectedKindQueryBlocked ? null : selectedKindQuery.data?.printerTable ?? null}
355
377
  connectionState={connection.state}
356
378
  largeListGuard={largeListGuard}
357
379
  onSelectedKindChange={setSelectedKind}
@@ -394,7 +416,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
394
416
  initialYaml={createDialogYaml}
395
417
  title={createDialogTitle}
396
418
  onCreated={(result) => {
397
- onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
419
+ const group = apiVersionToGroup(result.apiVersion)
420
+ onResourceClick?.({ kind: kindToPluralWithGroup(result.kind, group), namespace: result.namespace, name: result.name, group })
398
421
  }}
399
422
  />
400
423
  </>
@@ -4,8 +4,8 @@ import { useScaleWorkload, fetchJSON } from '../../../api/client'
4
4
  import { useRBACSubject } from '../../../api/rbac'
5
5
  import { usePolicyResource } from '../../../api/policy'
6
6
  import { useQueries, useQueryClient } from '@tanstack/react-query'
7
- import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
8
- import type { Relationships, ResourceRef, ResourceWithRelationships } from '../../../types'
7
+ import { kindToPlural, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
8
+ import type { Relationships, ResourceRef, ResourceWithRelationships, WorkloadPodInfo } from '../../../types'
9
9
  import type { ScalerDiagnosis } from '@skyhook-io/k8s-ui/components/resources/renderers/WorkloadRenderer'
10
10
 
11
11
  // Map plural lowercase kind to singular PascalCase for ownerReferences matching
@@ -26,9 +26,10 @@ interface WorkloadRendererProps {
26
26
  onNavigate?: (ref: ResourceRef) => void
27
27
  relationships?: Relationships
28
28
  scaleBlockedBy?: ResourceRef[]
29
+ workloadPods?: WorkloadPodInfo[]
29
30
  }
30
31
 
31
- export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: WorkloadRendererProps) {
32
+ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy, workloadPods }: WorkloadRendererProps) {
32
33
  const navigate = useNavigate()
33
34
  const queryClient = useQueryClient()
34
35
  const scaleMutation = useScaleWorkload()
@@ -54,13 +55,19 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
54
55
  })
55
56
  const hpaQueries = useQueries({
56
57
  queries: hpaRefs.map(ref => ({
57
- queryKey: ['resource', kindToPlural(ref.kind), ref.namespace, ref.name, ref.group],
58
+ queryKey: [
59
+ 'resource',
60
+ kindToPluralWithGroup(ref.kind, ref.group ?? ''),
61
+ ref.namespace,
62
+ ref.name,
63
+ ref.group,
64
+ ],
58
65
  queryFn: () => {
59
66
  const ns = ref.namespace || '_'
60
67
  const params = new URLSearchParams()
61
68
  if (ref.group) params.set('group', ref.group)
62
69
  const query = params.toString()
63
- return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPlural(ref.kind)}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
70
+ return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPluralWithGroup(ref.kind, ref.group ?? '')}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
64
71
  },
65
72
  enabled: Boolean(ref.kind && ref.name),
66
73
  staleTime: 10000,
@@ -90,6 +97,7 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
90
97
  policyLoading={policyLoading}
91
98
  policyError={policyError as Error | null}
92
99
  scaleBlockedBy={scaleBlockedBy}
100
+ workloadPods={workloadPods}
93
101
  scalerDiagnostics={scalerDiagnostics}
94
102
  onScale={async (replicas) => {
95
103
  await scaleMutation.mutateAsync({
@@ -33,11 +33,17 @@ import {
33
33
  type ResourceSignal,
34
34
  type RightsizingActionTone,
35
35
  } from './presentation'
36
+ import { useNavCustomization } from '../../context/NavCustomization'
36
37
 
37
38
  export const RIGHTSIZING_SCAN_DESCRIPTION =
38
39
  'Find CPU and memory requests to increase, reduce, or review. Radar never changes them.'
39
40
  export const RIGHTSIZING_SCAN_METHODOLOGY =
40
41
  'Based on 7 days of history: CPU P95 and memory maximum, plus 15% headroom. Memory reductions require verifiable restart history.'
42
+ export const RIGHTSIZING_METRICS_REQUIRED_TITLE = 'Metrics history is required'
43
+ export const RIGHTSIZING_METRICS_REQUIRED_BODY =
44
+ 'Rightsizing needs 7 days of Kubernetes workload history from a PromQL-compatible metrics backend (Prometheus, VictoriaMetrics, Thanos, or Mimir).\nCost Overview remains available without it.'
45
+ export const RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY =
46
+ `${RIGHTSIZING_METRICS_REQUIRED_BODY}\nConfigure metrics for this cluster in the host application or Radar deployment.`
41
47
 
42
48
  export type RightsizingScanSurfaceState =
43
49
  | 'discovering'
@@ -97,6 +103,7 @@ const ACTION_META: Record<
97
103
 
98
104
  export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
99
105
  useAutoPromConnect()
106
+ const settingsAvailable = !useNavCustomization().embedded
100
107
  const navigate = useNavigate()
101
108
  const [params, setParams] = useSearchParams()
102
109
  const { data: clusterInfo } = useClusterInfo()
@@ -242,16 +249,33 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
242
249
  />
243
250
  ) : surfaceState === 'prometheus_required' ? (
244
251
  <CenteredState
245
- title="Prometheus is required"
246
- body="Rightsizing uses Prometheus history. Cost Overview can still use OpenCost independently."
252
+ title={RIGHTSIZING_METRICS_REQUIRED_TITLE}
253
+ body={settingsAvailable
254
+ ? RIGHTSIZING_METRICS_REQUIRED_BODY
255
+ : RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY}
247
256
  action={
248
- <button
249
- type="button"
250
- onClick={() => retryPrometheus()}
251
- className="btn-brand px-3 py-1.5 text-xs font-medium"
252
- >
253
- Check again
254
- </button>
257
+ <div className="flex flex-wrap items-center justify-center gap-3">
258
+ {settingsAvailable && (
259
+ <button
260
+ type="button"
261
+ onClick={() =>
262
+ window.dispatchEvent(
263
+ new CustomEvent('radar:open-settings', { detail: { section: 'prometheus' } }),
264
+ )
265
+ }
266
+ className="btn-brand px-4 py-2 text-sm font-medium"
267
+ >
268
+ Configure metrics
269
+ </button>
270
+ )}
271
+ <button
272
+ type="button"
273
+ onClick={() => retryPrometheus()}
274
+ className="rounded-lg border border-theme-border px-4 py-2 text-sm font-medium text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
275
+ >
276
+ Check again
277
+ </button>
278
+ </div>
255
279
  }
256
280
  />
257
281
  ) : surfaceState === 'first_run' ? (
@@ -860,15 +884,15 @@ function CenteredState({
860
884
  action?: React.ReactNode
861
885
  }) {
862
886
  return (
863
- <div className="flex min-h-48 items-center justify-center rounded-xl border border-theme-border bg-theme-surface">
864
- <div className="flex max-w-lg flex-col items-center px-6 text-center">
887
+ <div className="flex min-h-56 items-center justify-center rounded-xl border border-theme-border bg-theme-surface px-6 py-8">
888
+ <div className="flex w-full max-w-xl flex-col items-center text-center">
865
889
  {loading ? (
866
890
  <Loader2 className="h-8 w-8 animate-spin text-theme-text-tertiary" />
867
891
  ) : (
868
892
  <Gauge className="h-8 w-8 text-theme-text-tertiary" />
869
893
  )}
870
894
  <h2 className="mt-3 text-base font-semibold text-theme-text-primary">{title}</h2>
871
- <p className="mt-1 text-sm text-theme-text-secondary">{body}</p>
895
+ <p className="mt-1 whitespace-pre-line text-sm text-theme-text-secondary">{body}</p>
872
896
  {action && <div className="mt-4">{action}</div>}
873
897
  </div>
874
898
  </div>
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  getRightsizingScanSurfaceState,
4
+ RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY,
5
+ RIGHTSIZING_METRICS_REQUIRED_BODY,
6
+ RIGHTSIZING_METRICS_REQUIRED_TITLE,
4
7
  RIGHTSIZING_SCAN_DESCRIPTION,
5
8
  RIGHTSIZING_SCAN_METHODOLOGY,
6
9
  } from './RightsizingScanView'
@@ -17,6 +20,22 @@ describe('rightsizing scan copy', () => {
17
20
  expect(copy.toLowerCase()).not.toContain('savings')
18
21
  })
19
22
 
23
+ it('describes the metrics contract without assuming one provider or cost source', () => {
24
+ const copy = `${RIGHTSIZING_METRICS_REQUIRED_TITLE} ${RIGHTSIZING_METRICS_REQUIRED_BODY}`
25
+ expect(copy).toContain('Metrics history')
26
+ expect(copy).toContain('PromQL-compatible metrics backend')
27
+ expect(copy).toContain('7 days')
28
+ expect(copy).toContain('Prometheus, VictoriaMetrics, Thanos, or Mimir')
29
+ expect(RIGHTSIZING_METRICS_REQUIRED_BODY).toContain('\nCost Overview')
30
+ expect(copy).not.toContain('OpenCost')
31
+ expect(copy).not.toContain('Kubecost')
32
+ })
33
+
34
+ it('routes embedded configuration through the host instead of standalone Settings', () => {
35
+ expect(RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY).toContain('host application')
36
+ expect(RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY).not.toContain('Settings')
37
+ })
38
+
20
39
  it('retains a prior snapshot after a failed rerun but treats a first-run failure as fatal', () => {
21
40
  expect(
22
41
  getRightsizingScanSurfaceState({