@skyhook-io/radar-app 1.10.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 +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- 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/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- 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/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- 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/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -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,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
-
import { AlertCircle,
|
|
2
|
+
import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-react'
|
|
3
3
|
import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import {
|
|
5
5
|
COST_DISCOVERY_GRACE_MS,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
16
|
import { ChartLegend, CostTimeRangeSelector, StackedAreaChart } from './CostTrendChart'
|
|
17
17
|
import {
|
|
18
|
+
DEFAULT_COST_CURRENCY,
|
|
18
19
|
formatCostPerHour,
|
|
19
20
|
formatHistoricalSpend,
|
|
20
21
|
formatProjectedDailyRate,
|
|
@@ -148,6 +149,8 @@ export function ApplicationCostTab({
|
|
|
148
149
|
const hasTrend = points.length >= 2 && points.some((p) => p.value > 0)
|
|
149
150
|
const rows = current?.workloads ?? []
|
|
150
151
|
const maxCost = Math.max(...rows.map((row) => row.current?.hourlyCost ?? 0), 0)
|
|
152
|
+
const currentCurrency = current?.currency ?? trend?.currency ?? DEFAULT_COST_CURRENCY
|
|
153
|
+
const trendCurrency = trend?.currency ?? current?.currency ?? DEFAULT_COST_CURRENCY
|
|
151
154
|
|
|
152
155
|
return (
|
|
153
156
|
<div className="mx-auto w-full max-w-[1600px] space-y-4">
|
|
@@ -182,11 +185,11 @@ export function ApplicationCostTab({
|
|
|
182
185
|
<div className="text-sm font-semibold text-theme-text-primary">
|
|
183
186
|
Application compute cost
|
|
184
187
|
</div>
|
|
185
|
-
<CostInfoTooltip content="
|
|
188
|
+
<CostInfoTooltip content="Values are based on OpenCost CPU and memory allocation over time, grouped by the workloads in this application. OpenCost allocation uses the greater of requested or observed resources." />
|
|
186
189
|
</div>
|
|
187
190
|
<div className="text-xs text-theme-text-tertiary">
|
|
188
|
-
OpenCost CPU and memory allocation rate (
|
|
189
|
-
DaemonSet workloads
|
|
191
|
+
OpenCost CPU and memory allocation rate ({trendCurrency}/hr) for Deployment,
|
|
192
|
+
StatefulSet, and DaemonSet workloads
|
|
190
193
|
</div>
|
|
191
194
|
</div>
|
|
192
195
|
</div>
|
|
@@ -201,6 +204,7 @@ export function ApplicationCostTab({
|
|
|
201
204
|
points.length,
|
|
202
205
|
trend?.windowTotalCost ?? 0,
|
|
203
206
|
trendLoading || state === 'partial_missing_history',
|
|
207
|
+
trendCurrency,
|
|
204
208
|
)}
|
|
205
209
|
subvalue={
|
|
206
210
|
state === 'partial_missing_history'
|
|
@@ -210,10 +214,10 @@ export function ApplicationCostTab({
|
|
|
210
214
|
/>
|
|
211
215
|
<CostMetricBlock
|
|
212
216
|
label="Projected monthly"
|
|
213
|
-
value={totals ? formatProjectedMonthlyCost(hourly) : '—'}
|
|
217
|
+
value={totals ? formatProjectedMonthlyCost(hourly, currentCurrency) : '—'}
|
|
214
218
|
subvalue={
|
|
215
219
|
totals
|
|
216
|
-
? `${formatCostPerHour(hourly)} current rate`
|
|
220
|
+
? `${formatCostPerHour(hourly, currentCurrency)} current rate`
|
|
217
221
|
: 'Current allocation unavailable'
|
|
218
222
|
}
|
|
219
223
|
/>
|
|
@@ -226,7 +230,7 @@ export function ApplicationCostTab({
|
|
|
226
230
|
</div>
|
|
227
231
|
) : hasTrend && chartSeries.length > 0 ? (
|
|
228
232
|
<div className="min-w-0">
|
|
229
|
-
<StackedAreaChart series={chartSeries} />
|
|
233
|
+
<StackedAreaChart series={chartSeries} currency={trendCurrency} />
|
|
230
234
|
<ChartLegend series={chartSeries} />
|
|
231
235
|
</div>
|
|
232
236
|
) : (
|
|
@@ -250,16 +254,17 @@ export function ApplicationCostTab({
|
|
|
250
254
|
/>
|
|
251
255
|
<CostMetricTile
|
|
252
256
|
label="Projected daily"
|
|
253
|
-
value={totals ? formatProjectedDailyRate(hourly) : '—'}
|
|
257
|
+
value={totals ? formatProjectedDailyRate(hourly, currentCurrency) : '—'}
|
|
254
258
|
subvalue={
|
|
255
259
|
totals
|
|
256
|
-
? `${formatCostPerHour(hourly)} current hourly rate`
|
|
260
|
+
? `${formatCostPerHour(hourly, currentCurrency)} current hourly rate`
|
|
257
261
|
: 'Current allocation unavailable'
|
|
258
262
|
}
|
|
259
263
|
/>
|
|
260
264
|
</div>
|
|
261
265
|
|
|
262
266
|
<CurrentAllocationUse
|
|
267
|
+
currency={currentCurrency}
|
|
263
268
|
dataAvailable={Boolean(totals)}
|
|
264
269
|
cpuCost={totals?.cpuCost ?? 0}
|
|
265
270
|
memoryCost={totals?.memoryCost ?? 0}
|
|
@@ -296,6 +301,7 @@ export function ApplicationCostTab({
|
|
|
296
301
|
key={applicationCostKey(row)}
|
|
297
302
|
row={row}
|
|
298
303
|
maxCost={maxCost}
|
|
304
|
+
currency={currentCurrency}
|
|
299
305
|
onOpen={
|
|
300
306
|
appWorkload && onSelectWorkloadCost
|
|
301
307
|
? () => onSelectWorkloadCost(appWorkload)
|
|
@@ -309,9 +315,13 @@ export function ApplicationCostTab({
|
|
|
309
315
|
</section>
|
|
310
316
|
|
|
311
317
|
<div className="text-xs text-theme-text-tertiary">
|
|
312
|
-
Powered by OpenCost via Prometheus.
|
|
313
|
-
|
|
314
|
-
|
|
318
|
+
Powered by OpenCost via Prometheus.{' '}
|
|
319
|
+
{currentCurrency !== DEFAULT_COST_CURRENCY && (
|
|
320
|
+
<>Labeled {currentCurrency}; no conversion. </>
|
|
321
|
+
)}
|
|
322
|
+
Historical spend uses the selected range; projected monthly values multiply current hourly
|
|
323
|
+
allocation. Batch/job cost is separate; storage/PVC and network costs remain at namespace
|
|
324
|
+
and cluster level.
|
|
315
325
|
</div>
|
|
316
326
|
</div>
|
|
317
327
|
)
|
|
@@ -361,10 +371,12 @@ export function applicationCostWorkloads(workloads: AppWorkload[]): AppWorkload[
|
|
|
361
371
|
function ApplicationWorkloadCostRow({
|
|
362
372
|
row,
|
|
363
373
|
maxCost,
|
|
374
|
+
currency,
|
|
364
375
|
onOpen,
|
|
365
376
|
}: {
|
|
366
377
|
row: OpenCostApplicationWorkloadCost
|
|
367
378
|
maxCost: number
|
|
379
|
+
currency: string
|
|
368
380
|
onOpen?: () => void
|
|
369
381
|
}) {
|
|
370
382
|
const current = row.current
|
|
@@ -392,10 +404,10 @@ function ApplicationWorkloadCostRow({
|
|
|
392
404
|
)}
|
|
393
405
|
</div>
|
|
394
406
|
<div className="text-right text-sm font-medium tabular-nums text-theme-text-primary">
|
|
395
|
-
{current ? formatProjectedMonthlyRate(hourly) : '—'}
|
|
407
|
+
{current ? formatProjectedMonthlyRate(hourly, currency) : '—'}
|
|
396
408
|
</div>
|
|
397
409
|
<div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary sm:block">
|
|
398
|
-
{current ? formatCostPerHour(hourly) : '—'}
|
|
410
|
+
{current ? formatCostPerHour(hourly, currency) : '—'}
|
|
399
411
|
</div>
|
|
400
412
|
<div className="hidden min-w-0 items-center gap-2 md:flex">
|
|
401
413
|
<div
|
|
@@ -410,7 +422,7 @@ function ApplicationWorkloadCostRow({
|
|
|
410
422
|
</div>
|
|
411
423
|
<div className="hidden text-right text-xs tabular-nums text-theme-text-tertiary lg:block">
|
|
412
424
|
{current
|
|
413
|
-
? `${formatProjectedMonthlyCost(current.cpuCost)} / ${formatProjectedMonthlyCost(current.memoryCost)}`
|
|
425
|
+
? `${formatProjectedMonthlyCost(current.cpuCost, currency)} / ${formatProjectedMonthlyCost(current.memoryCost, currency)}`
|
|
414
426
|
: '—'}
|
|
415
427
|
</div>
|
|
416
428
|
</>
|
|
@@ -512,7 +524,7 @@ function ApplicationCostUnavailable({
|
|
|
512
524
|
return (
|
|
513
525
|
<div className="flex h-full min-h-[320px] items-center justify-center">
|
|
514
526
|
<div className="flex max-w-md flex-col items-center gap-3 text-center text-theme-text-secondary">
|
|
515
|
-
<
|
|
527
|
+
<Coins className="h-8 w-8 text-theme-text-tertiary/50" />
|
|
516
528
|
<div className="text-sm">{text}</div>
|
|
517
529
|
</div>
|
|
518
530
|
</div>
|
|
@@ -2,7 +2,7 @@ import { useState, useMemo, useRef, useCallback } from 'react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Loader2, TrendingUp } from 'lucide-react'
|
|
4
4
|
import { useOpenCostTrend, type CostTimeRange, type OpenCostTrendSeries } from '../../api/client'
|
|
5
|
-
import { formatCostAxis, formatCostPerHour } from './format'
|
|
5
|
+
import { DEFAULT_COST_CURRENCY, formatCostAxis, formatCostPerHour } from './format'
|
|
6
6
|
|
|
7
7
|
const SERIES_COLORS = [
|
|
8
8
|
'#3b82f6', // blue-500
|
|
@@ -41,6 +41,8 @@ export function CostTrendChart() {
|
|
|
41
41
|
return null
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const currency = data.currency ?? DEFAULT_COST_CURRENCY
|
|
45
|
+
|
|
44
46
|
return (
|
|
45
47
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50">
|
|
46
48
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-theme-border">
|
|
@@ -48,20 +50,28 @@ export function CostTrendChart() {
|
|
|
48
50
|
<TrendingUp className="w-4 h-4 text-theme-text-tertiary" />
|
|
49
51
|
<div>
|
|
50
52
|
<div className="text-xs font-medium text-theme-text-secondary">Cost rate trend</div>
|
|
51
|
-
<div className="text-[10px] text-theme-text-tertiary">
|
|
53
|
+
<div className="text-[10px] text-theme-text-tertiary">
|
|
54
|
+
Historical OpenCost allocation rate ({currency}/hr)
|
|
55
|
+
</div>
|
|
52
56
|
</div>
|
|
53
57
|
</div>
|
|
54
58
|
<CostTimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
|
55
59
|
</div>
|
|
56
60
|
<div className="p-4">
|
|
57
|
-
<StackedAreaChart series={data.series} />
|
|
61
|
+
<StackedAreaChart series={data.series} currency={currency} />
|
|
58
62
|
<ChartLegend series={data.series} />
|
|
59
63
|
</div>
|
|
60
64
|
</div>
|
|
61
65
|
)
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
export function StackedAreaChart({
|
|
68
|
+
export function StackedAreaChart({
|
|
69
|
+
series,
|
|
70
|
+
currency,
|
|
71
|
+
}: {
|
|
72
|
+
series: OpenCostTrendSeries[]
|
|
73
|
+
currency: string
|
|
74
|
+
}) {
|
|
65
75
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
66
76
|
const [hoverX, setHoverX] = useState<number | null>(null)
|
|
67
77
|
|
|
@@ -123,7 +133,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
|
|
|
123
133
|
const tickCount = 4
|
|
124
134
|
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
|
125
135
|
const val = (yMax / tickCount) * i
|
|
126
|
-
return { val, y: toY(val), label: formatCostAxis(val) }
|
|
136
|
+
return { val, y: toY(val), label: formatCostAxis(val, currency) }
|
|
127
137
|
})
|
|
128
138
|
|
|
129
139
|
// X axis ticks
|
|
@@ -175,7 +185,7 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
|
|
|
175
185
|
xTicks,
|
|
176
186
|
paths,
|
|
177
187
|
}
|
|
178
|
-
}, [series, plotHeight, plotWidth])
|
|
188
|
+
}, [series, currency, plotHeight, plotWidth])
|
|
179
189
|
|
|
180
190
|
// Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
|
|
181
191
|
const hoverData = useMemo(() => {
|
|
@@ -341,14 +351,14 @@ export function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] })
|
|
|
341
351
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: p.color }} />
|
|
342
352
|
<span className="text-theme-text-secondary">{p.namespace}</span>
|
|
343
353
|
<span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
|
|
344
|
-
{formatCostTooltip(p.value)}
|
|
354
|
+
{formatCostTooltip(p.value, currency)}
|
|
345
355
|
</span>
|
|
346
356
|
</div>
|
|
347
357
|
))}
|
|
348
358
|
{series.length > 1 && (
|
|
349
359
|
<div className="border-t border-theme-border/50 mt-1 pt-1 flex justify-between text-theme-text-primary font-semibold">
|
|
350
360
|
<span>Total</span>
|
|
351
|
-
<span className="tabular-nums">{formatCostTooltip(hoverData.total)}</span>
|
|
361
|
+
<span className="tabular-nums">{formatCostTooltip(hoverData.total, currency)}</span>
|
|
352
362
|
</div>
|
|
353
363
|
)}
|
|
354
364
|
</div>
|
|
@@ -403,8 +413,8 @@ export function CostTimeRangeSelector({
|
|
|
403
413
|
)
|
|
404
414
|
}
|
|
405
415
|
|
|
406
|
-
function formatCostTooltip(value: number): string {
|
|
407
|
-
return formatCostPerHour(value)
|
|
416
|
+
function formatCostTooltip(value: number, currency: string): string {
|
|
417
|
+
return formatCostPerHour(value, currency)
|
|
408
418
|
}
|
|
409
419
|
|
|
410
420
|
function formatTimestamp(unix: number): string {
|