@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.
- package/package.json +6 -6
- package/src/App.tsx +35 -15
- package/src/api/client.images.test.ts +63 -0
- package/src/api/client.ts +246 -39
- package/src/api/client.yaml.test.ts +3 -3
- package/src/api/version-check.test.ts +78 -0
- package/src/components/CloudConnectFlow.tsx +46 -26
- package/src/components/CloudFunnelButton.tsx +166 -129
- package/src/components/ConnectionErrorView.test.tsx +21 -1
- package/src/components/ConnectionErrorView.tsx +7 -8
- package/src/components/applications/ApplicationsView.tsx +10 -9
- package/src/components/audit/AuditView.tsx +6 -3
- package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
- package/src/components/audit/UpgradeReadinessView.tsx +19 -9
- package/src/components/cost/ApplicationCostTab.test.ts +45 -0
- package/src/components/cost/ApplicationCostTab.tsx +115 -64
- package/src/components/cost/CostTrendChart.test.ts +65 -0
- package/src/components/cost/CostTrendChart.tsx +107 -17
- package/src/components/cost/CostView.test.ts +36 -1
- package/src/components/cost/CostView.tsx +133 -51
- package/src/components/cost/CurrentAllocationUse.tsx +8 -4
- package/src/components/cost/WorkloadCostTab.test.ts +50 -0
- package/src/components/cost/WorkloadCostTab.tsx +109 -61
- package/src/components/cost/source.test.ts +33 -0
- package/src/components/cost/source.ts +100 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +19 -15
- package/src/components/gitops/GitOpsView.tsx +8 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
- package/src/components/helm/OwnedResources.tsx +10 -2
- package/src/components/home/ClusterHealthCard.test.ts +31 -0
- package/src/components/home/ClusterHealthCard.tsx +60 -1
- package/src/components/home/CostCard.tsx +3 -2
- package/src/components/home/HomeView.tsx +36 -11
- package/src/components/home/MCPSetupDialog.tsx +5 -4
- package/src/components/home/RadarVersionLine.test.tsx +145 -0
- package/src/components/home/RadarVersionLine.tsx +137 -0
- package/src/components/resources/PodFilesystemModal.tsx +54 -2
- package/src/components/resources/ResourcesView.tsx +31 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +687 -107
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/components/ui/ErrorBoundary.test.tsx +55 -0
- package/src/components/ui/ErrorBoundary.tsx +17 -2
- package/src/components/ui/UpdateNotification.test.tsx +49 -0
- package/src/components/ui/UpdateNotification.tsx +6 -2
- package/src/components/workload/WorkloadView.test.ts +60 -0
- package/src/components/workload/WorkloadView.tsx +270 -29
- package/src/contexts/CapabilitiesContext.test.tsx +29 -0
- package/src/contexts/CapabilitiesContext.tsx +7 -3
- package/src/index.css +11 -1
- package/src/utils/navigation.test.ts +45 -0
- package/src/utils/navigation.ts +5 -5
- package/src/utils/topology-selection.ts +3 -2
- package/src/utils/version.test.ts +37 -0
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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/${
|
|
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=
|
|
246
|
-
body=
|
|
252
|
+
title={RIGHTSIZING_METRICS_REQUIRED_TITLE}
|
|
253
|
+
body={settingsAvailable
|
|
254
|
+
? RIGHTSIZING_METRICS_REQUIRED_BODY
|
|
255
|
+
: RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY}
|
|
247
256
|
action={
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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-
|
|
864
|
-
<div className="flex max-w-
|
|
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({
|