@skyhook-io/radar-app 1.11.0 → 1.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/App.tsx +3 -2
- package/src/api/client.ts +36 -6
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/mcpToolCatalog.ts +12 -0
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +2 -1
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.2",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@fontsource-variable/dm-sans": "^5.2.8",
|
|
33
|
-
"@fontsource/dm-mono": "^5.
|
|
33
|
+
"@fontsource/dm-mono": "^5.3.0",
|
|
34
34
|
"@monaco-editor/react": "^4.7.0",
|
|
35
35
|
"diff": "^9.0.0",
|
|
36
36
|
"monaco-editor": "0.55.1",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.14.0",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"@types/node": "^26.2.0",
|
|
63
63
|
"@types/react": "^19.2.18",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
|
-
"@vitejs/plugin-react": "^6.0
|
|
65
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
66
66
|
"@xyflow/react": "^12.11.2",
|
|
67
67
|
"clsx": "^2.1.1",
|
|
68
68
|
"elkjs": "^0.11.1",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
71
71
|
"eslint-plugin-react-refresh": "^0.5.3",
|
|
72
72
|
"globals": "^17.9.0",
|
|
73
|
-
"lucide-react": "^1.
|
|
74
|
-
"postcss": "^8.5.
|
|
73
|
+
"lucide-react": "^1.33.0",
|
|
74
|
+
"postcss": "^8.5.26",
|
|
75
75
|
"prettier": "^3.9.5",
|
|
76
76
|
"react": "^19.2.8",
|
|
77
77
|
"react-dom": "^19.2.8",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"tailwind-merge": "^3.6.0",
|
|
80
80
|
"tailwindcss": "^4.3.3",
|
|
81
81
|
"typescript": "^6.0.2",
|
|
82
|
-
"typescript-eslint": "^8.
|
|
82
|
+
"typescript-eslint": "^8.67.0",
|
|
83
83
|
"vite": "^8.2.1"
|
|
84
84
|
},
|
|
85
85
|
"sideEffects": [
|
package/src/App.tsx
CHANGED
|
@@ -1664,8 +1664,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1664
1664
|
{/* `relative` makes this column the containing block for the absolute
|
|
1665
1665
|
overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
|
|
1666
1666
|
span the content area AFTER the rail rather than the full viewport
|
|
1667
|
-
under it.
|
|
1668
|
-
|
|
1667
|
+
under it. Horizontal clipping keeps translated-offscreen drawers from
|
|
1668
|
+
contributing document overflow. `fixed` splashes are unaffected. */}
|
|
1669
|
+
<div className="relative flex flex-col flex-1 min-w-0 h-full overflow-x-clip">
|
|
1669
1670
|
{/* Header — suppressed in chromeless embed; the host owns the chrome.
|
|
1670
1671
|
@container: the header's responsive layout keys off its OWN width (≈ viewport
|
|
1671
1672
|
− nav rail), not the viewport, so it collapses gracefully on narrow windows.
|
package/src/api/client.ts
CHANGED
|
@@ -481,6 +481,12 @@ export interface UpgradeReadinessResponse {
|
|
|
481
481
|
currentVersion: string
|
|
482
482
|
targetVersion: string
|
|
483
483
|
reviewedThrough: string
|
|
484
|
+
// When the underlying scan ran. The server memoizes scans (~60s), so a
|
|
485
|
+
// response can be older than the fetch that returned it — surface this,
|
|
486
|
+
// not dataUpdatedAt, as the scan time.
|
|
487
|
+
observedAt: string
|
|
488
|
+
scanId: string
|
|
489
|
+
fromCache?: boolean
|
|
484
490
|
verdict: UpgradeReadinessVerdict
|
|
485
491
|
summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number; findings: number }
|
|
486
492
|
checks: UpgradeReadinessCheck[]
|
|
@@ -590,15 +596,28 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
590
596
|
}
|
|
591
597
|
|
|
592
598
|
export function useUpgradeReadiness(target?: string) {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
599
|
+
// The server memoizes scans per caller (~60s). Ordinary and background
|
|
600
|
+
// refetches read that memo; only the explicit manual refresh passes
|
|
601
|
+
// refresh=true to force a fresh live scan (e.g. after fixing a finding).
|
|
602
|
+
const forceRefresh = useRef(false)
|
|
603
|
+
const query = useQuery<UpgradeReadinessResponse>({
|
|
597
604
|
queryKey: ['upgrade-readiness', target ?? 'next'],
|
|
598
|
-
queryFn: ({ signal }) =>
|
|
605
|
+
queryFn: ({ signal }) => {
|
|
606
|
+
const params = new URLSearchParams()
|
|
607
|
+
if (target) params.set('target', target)
|
|
608
|
+
if (forceRefresh.current) params.set('refresh', 'true')
|
|
609
|
+
forceRefresh.current = false
|
|
610
|
+
const qs = params.toString()
|
|
611
|
+
return fetchJSON(`/upgrade-readiness${qs ? `?${qs}` : ''}`, signal)
|
|
612
|
+
},
|
|
599
613
|
staleTime: 30000,
|
|
600
614
|
placeholderData: (previous) => previous,
|
|
601
615
|
})
|
|
616
|
+
const refreshScan = () => {
|
|
617
|
+
forceRefresh.current = true
|
|
618
|
+
return query.refetch()
|
|
619
|
+
}
|
|
620
|
+
return { ...query, refreshScan }
|
|
602
621
|
}
|
|
603
622
|
|
|
604
623
|
// Live cluster Issues — the grouped triage queue (radar's /api/issues =
|
|
@@ -937,6 +956,7 @@ export interface OpenCostWorkloadCost {
|
|
|
937
956
|
export interface OpenCostWorkloadResponse {
|
|
938
957
|
available: boolean;
|
|
939
958
|
reason?: CostUnavailableReason;
|
|
959
|
+
currency?: string;
|
|
940
960
|
namespace: string;
|
|
941
961
|
workloads: OpenCostWorkloadCost[];
|
|
942
962
|
}
|
|
@@ -964,6 +984,7 @@ export function useOpenCostWorkloads(
|
|
|
964
984
|
export interface OpenCostWorkloadDetailResponse {
|
|
965
985
|
available: boolean;
|
|
966
986
|
reason?: CostUnavailableReason;
|
|
987
|
+
currency?: string;
|
|
967
988
|
namespace: string;
|
|
968
989
|
kind: string;
|
|
969
990
|
name: string;
|
|
@@ -1008,6 +1029,7 @@ export interface OpenCostTrendSeries {
|
|
|
1008
1029
|
export interface OpenCostTrendResponse {
|
|
1009
1030
|
available: boolean;
|
|
1010
1031
|
reason?: CostUnavailableReason;
|
|
1032
|
+
currency?: string;
|
|
1011
1033
|
range: string;
|
|
1012
1034
|
series?: OpenCostTrendSeries[];
|
|
1013
1035
|
}
|
|
@@ -1029,6 +1051,7 @@ export function useOpenCostTrend(range_: CostTimeRange = "24h") {
|
|
|
1029
1051
|
export interface OpenCostWorkloadTrendResponse {
|
|
1030
1052
|
available: boolean;
|
|
1031
1053
|
reason?: CostUnavailableReason;
|
|
1054
|
+
currency?: string;
|
|
1032
1055
|
namespace: string;
|
|
1033
1056
|
kind: string;
|
|
1034
1057
|
name: string;
|
|
@@ -1101,6 +1124,7 @@ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWork
|
|
|
1101
1124
|
export interface OpenCostApplicationCostResponse {
|
|
1102
1125
|
available: boolean;
|
|
1103
1126
|
reason?: CostUnavailableReason;
|
|
1127
|
+
currency?: string;
|
|
1104
1128
|
partial?: boolean;
|
|
1105
1129
|
totals: OpenCostApplicationCostTotals;
|
|
1106
1130
|
coverage: OpenCostApplicationCostCoverage;
|
|
@@ -1115,6 +1139,7 @@ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationW
|
|
|
1115
1139
|
export interface OpenCostApplicationCostTrendResponse {
|
|
1116
1140
|
available: boolean;
|
|
1117
1141
|
reason?: CostUnavailableReason;
|
|
1142
|
+
currency?: string;
|
|
1118
1143
|
range: string;
|
|
1119
1144
|
partial?: boolean;
|
|
1120
1145
|
windowTotalCost?: number;
|
|
@@ -1206,6 +1231,7 @@ export interface OpenCostNodeCost {
|
|
|
1206
1231
|
export interface OpenCostNodeResponse {
|
|
1207
1232
|
available: boolean;
|
|
1208
1233
|
reason?: CostUnavailableReason;
|
|
1234
|
+
currency?: string;
|
|
1209
1235
|
nodes?: OpenCostNodeCost[];
|
|
1210
1236
|
}
|
|
1211
1237
|
|
|
@@ -3104,6 +3130,7 @@ export interface ArgoStatus {
|
|
|
3104
3130
|
configured: boolean;
|
|
3105
3131
|
connected: boolean;
|
|
3106
3132
|
address?: string;
|
|
3133
|
+
reason?: string;
|
|
3107
3134
|
}
|
|
3108
3135
|
|
|
3109
3136
|
export function useArgoStatus(enabled = true) {
|
|
@@ -6405,10 +6432,13 @@ export interface DiagnosticsSnapshot {
|
|
|
6405
6432
|
errorType?: string;
|
|
6406
6433
|
};
|
|
6407
6434
|
kubeconfig?: {
|
|
6408
|
-
mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir";
|
|
6435
|
+
mode: "" | "in-cluster" | "single" | "multi-env" | "multi-dir" | "multi-source";
|
|
6409
6436
|
fileCount: number;
|
|
6437
|
+
directoryFileCount: number;
|
|
6410
6438
|
contextCount: number;
|
|
6411
6439
|
enrichedFromShell: boolean;
|
|
6440
|
+
kubeconfigEnvIgnored: boolean;
|
|
6441
|
+
kubeconfigEnvIgnoredReason: string;
|
|
6412
6442
|
currentContextUsesExec: boolean;
|
|
6413
6443
|
execPluginsPresent?: string[];
|
|
6414
6444
|
execPluginsMissing?: string[];
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { ContextInfo } from '../types'
|
|
4
5
|
|
|
5
6
|
vi.stubGlobal('window', { location: { host: 'localhost:9280' } })
|
|
6
7
|
|
|
8
|
+
const useContextsMock = vi.hoisted(() => vi.fn(
|
|
9
|
+
(): { data: ContextInfo[] | undefined } => ({ data: undefined }),
|
|
10
|
+
))
|
|
11
|
+
|
|
7
12
|
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
8
13
|
ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
|
|
9
14
|
useOpenLocalTerminal: () => vi.fn(),
|
|
10
15
|
}))
|
|
11
16
|
vi.mock('../api/client', () => ({
|
|
12
17
|
useAuthMe: () => ({ data: { authEnabled: false } }),
|
|
18
|
+
useContexts: useContextsMock,
|
|
13
19
|
}))
|
|
14
20
|
vi.mock('./ContextSwitcher', () => ({
|
|
15
21
|
ContextSwitcher: () => <button>Switch context</button>,
|
|
@@ -72,6 +78,34 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
72
78
|
expect(markup).not.toContain('aws sso login')
|
|
73
79
|
})
|
|
74
80
|
|
|
81
|
+
it('uses the original context for collision-qualified auth guidance', () => {
|
|
82
|
+
const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
83
|
+
const hints = selectConnectionHints('auth', `${original} (secondary)`, original)
|
|
84
|
+
|
|
85
|
+
expect(hints?.title).toBe('EKS Authentication Failed')
|
|
86
|
+
expect(hints?.fallbackCommand?.command).toBe(
|
|
87
|
+
'aws eks update-kubeconfig --name prod --region us-east-1',
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('wires the current context original name into auth guidance', () => {
|
|
92
|
+
const original = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
93
|
+
useContextsMock.mockReturnValueOnce({
|
|
94
|
+
data: [{
|
|
95
|
+
name: `${original} (secondary)`,
|
|
96
|
+
originalName: original,
|
|
97
|
+
cluster: original,
|
|
98
|
+
user: 'prod',
|
|
99
|
+
namespace: '',
|
|
100
|
+
isCurrent: true,
|
|
101
|
+
}],
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const markup = renderError('auth', `${original} (secondary)`)
|
|
105
|
+
expect(markup).toContain('update-kubeconfig')
|
|
106
|
+
expect(markup).toContain('us-east-1')
|
|
107
|
+
})
|
|
108
|
+
|
|
75
109
|
it('renders placeholder AKS commands without a run affordance', () => {
|
|
76
110
|
const markup = renderError('auth-rejected', 'clusterUser_platform_prod')
|
|
77
111
|
|
|
@@ -86,3 +120,22 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
86
120
|
expect(markup).not.toContain('aria-label="Run command in terminal"')
|
|
87
121
|
})
|
|
88
122
|
})
|
|
123
|
+
|
|
124
|
+
describe('ConnectionErrorView kubeconfig guidance', () => {
|
|
125
|
+
it('keeps the actionable error and context switch visible for a broken context', () => {
|
|
126
|
+
const markup = renderError('config', 'prod')
|
|
127
|
+
|
|
128
|
+
expect(markup).toContain('Cannot Load Cluster Configuration')
|
|
129
|
+
expect(markup).toContain('Kubeconfig Problem')
|
|
130
|
+
expect(markup).toContain('aria-expanded="false"')
|
|
131
|
+
expect(markup).toContain('id="connection-raw-error"')
|
|
132
|
+
expect(markup).toContain('safe error')
|
|
133
|
+
expect(markup).toContain('Switch context')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not offer context switching when no context was loaded', () => {
|
|
137
|
+
const markup = renderError('config', '')
|
|
138
|
+
|
|
139
|
+
expect(markup).not.toContain('Switch context')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -4,7 +4,7 @@ import type { ConnectionState } from '../context/ConnectionContext'
|
|
|
4
4
|
import { ContextSwitcher } from './ContextSwitcher'
|
|
5
5
|
import { parseContextName } from '../utils/context-name'
|
|
6
6
|
import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
|
|
7
|
-
import { useAuthMe } from '../api/client'
|
|
7
|
+
import { useAuthMe, useContexts } from '../api/client'
|
|
8
8
|
import { Tooltip } from './ui/Tooltip'
|
|
9
9
|
import { allShellSafe } from '../utils/shell-safe'
|
|
10
10
|
import { apiUrl } from '../api/config'
|
|
@@ -218,11 +218,11 @@ function getTimeoutHints(context: string): AuthHints | null {
|
|
|
218
218
|
|
|
219
219
|
const errorHints: Record<string, { title: string; hints: string[] }> = {
|
|
220
220
|
config: {
|
|
221
|
-
title: '
|
|
221
|
+
title: 'Kubeconfig Problem',
|
|
222
222
|
hints: [
|
|
223
|
-
'Radar could not
|
|
224
|
-
'If
|
|
225
|
-
'
|
|
223
|
+
'Radar could not load a usable kubeconfig for this context',
|
|
224
|
+
'If the file exists, check the local Radar logs for the exact parse or load failure',
|
|
225
|
+
'If no kubeconfig is configured, Radar checks ~/.kube/config; set KUBECONFIG or pass --kubeconfig <path> for another location',
|
|
226
226
|
],
|
|
227
227
|
},
|
|
228
228
|
rbac: {
|
|
@@ -321,16 +321,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
|
|
|
321
321
|
)
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
export function selectConnectionHints(errorType: string | undefined, context: string): AuthHints | null {
|
|
324
|
+
export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
|
|
325
|
+
const parsedContext = originalContext || context
|
|
325
326
|
switch (errorType) {
|
|
326
327
|
case 'auth':
|
|
327
|
-
return getAuthHints(
|
|
328
|
+
return getAuthHints(parsedContext)
|
|
328
329
|
case 'auth-rejected':
|
|
329
|
-
return getAuthRejectedHints(
|
|
330
|
+
return getAuthRejectedHints(parsedContext)
|
|
330
331
|
case 'auth-plugin-stuck':
|
|
331
332
|
return getAuthPluginStuckHints()
|
|
332
333
|
case 'timeout':
|
|
333
|
-
return getTimeoutHints(
|
|
334
|
+
return getTimeoutHints(parsedContext)
|
|
334
335
|
default:
|
|
335
336
|
return null
|
|
336
337
|
}
|
|
@@ -342,7 +343,9 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
342
343
|
const isAuthRejected = connection.errorType === 'auth-rejected'
|
|
343
344
|
const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
|
|
344
345
|
const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
|
|
345
|
-
const
|
|
346
|
+
const { data: contexts } = useContexts()
|
|
347
|
+
const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
|
|
348
|
+
const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
|
|
346
349
|
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
347
350
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
348
351
|
const { data: authMe } = useAuthMe()
|
|
@@ -384,7 +387,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
384
387
|
</div>
|
|
385
388
|
|
|
386
389
|
<h2 className="text-xl font-semibold text-theme-text-primary mb-2">
|
|
387
|
-
{connection.errorType === 'config' ? '
|
|
390
|
+
{connection.errorType === 'config' ? 'Cannot Load Cluster Configuration' : 'Cannot Connect to Cluster'}
|
|
388
391
|
</h2>
|
|
389
392
|
|
|
390
393
|
<div className="mb-6 space-y-1">
|
|
@@ -480,7 +483,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
480
483
|
)}
|
|
481
484
|
</button>
|
|
482
485
|
|
|
483
|
-
{connection.
|
|
486
|
+
{connection.context && <ContextSwitcher triggerName="Switch context" />}
|
|
484
487
|
</div>
|
|
485
488
|
|
|
486
489
|
{isAuthError && (
|
|
@@ -10,7 +10,7 @@ import { useContextSwitch } from '../context/ContextSwitchContext'
|
|
|
10
10
|
import { useToast } from '../components/ui/Toast'
|
|
11
11
|
import { useDock } from '../components/dock'
|
|
12
12
|
import type { ContextInfo } from '../types'
|
|
13
|
-
import {
|
|
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
|
-
...
|
|
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
|
-
//
|
|
192
|
-
//
|
|
184
|
+
// Keep the trigger name parseable and render any collision qualifier as a
|
|
185
|
+
// separate suffix so cloud-provider metadata remains intact.
|
|
193
186
|
// Fall back to clusterInfo.context for the very-early window before
|
|
194
187
|
// /api/contexts has resolved.
|
|
195
188
|
const currentParsed = currentId ? parsedById.get(currentId) : undefined
|
|
196
189
|
const currentRaw = triggerName || currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
|
|
190
|
+
const currentNameQualifier = triggerName
|
|
191
|
+
? undefined
|
|
192
|
+
: visibleContextQualifier(currentParsed?.nameQualifier, currentCtx?.source, hasMultipleSources)
|
|
197
193
|
const currentSourceLabel = triggerName ? undefined : hasMultipleSources ? currentCtx?.source || undefined : undefined
|
|
198
194
|
|
|
199
195
|
return (
|
|
@@ -205,6 +201,7 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
205
201
|
label={label}
|
|
206
202
|
currentId={currentId}
|
|
207
203
|
currentName={currentRaw}
|
|
204
|
+
currentNameQualifier={currentNameQualifier}
|
|
208
205
|
currentSourceLabel={currentSourceLabel}
|
|
209
206
|
items={items}
|
|
210
207
|
onSelect={handleSelect}
|
|
@@ -76,7 +76,7 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
76
76
|
const location = useLocation()
|
|
77
77
|
const navigate = useNavigate()
|
|
78
78
|
const requestedTarget = new URLSearchParams(location.search).get('target') ?? undefined
|
|
79
|
-
const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt,
|
|
79
|
+
const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt, refreshScan } = useUpgradeReadiness(requestedTarget)
|
|
80
80
|
const { connection } = useConnection()
|
|
81
81
|
const targetOptions = useMemo(
|
|
82
82
|
() => buildTargetOptions(data?.currentVersion, data?.reviewedThrough, requestedTarget ?? data?.targetVersion),
|
|
@@ -153,8 +153,8 @@ export function UpgradeReadinessView({ namespaces, onNavigateToResource }: Upgra
|
|
|
153
153
|
)}
|
|
154
154
|
<FreshnessControl
|
|
155
155
|
mode="snapshot"
|
|
156
|
-
dataUpdatedAt={dataUpdatedAt}
|
|
157
|
-
onRefresh={() =>
|
|
156
|
+
dataUpdatedAt={data.observedAt ? Date.parse(data.observedAt) : dataUpdatedAt}
|
|
157
|
+
onRefresh={() => refreshScan()}
|
|
158
158
|
connectionState={connection.state}
|
|
159
159
|
isFetching={isFetching}
|
|
160
160
|
/>
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
8
8
|
import {
|
|
9
9
|
parseCPUToNanocores,
|
|
10
|
-
|
|
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
|
-
//
|
|
100
|
-
// would read
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const parsed =
|
|
104
|
-
if (
|
|
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
|
-
|
|
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,
|