@skyhook-io/radar-app 1.12.3 → 1.13.1
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 +5 -5
- package/src/App.tsx +37 -8
- package/src/RadarApp.tsx +1 -1
- package/src/api/client.ts +38 -6
- package/src/api/diagnose.ts +45 -5
- 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/AISettings.tsx +9 -4
- package/src/components/diagnose/DiagnoseContext.tsx +216 -48
- package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
- package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
- package/src/components/diagnose/Home.tsx +93 -64
- package/src/components/diagnose/InvestigationView.tsx +190 -124
- package/src/components/diagnose/parts.test.tsx +12 -4
- package/src/components/diagnose/parts.tsx +29 -17
- package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
- package/src/components/execution/BatchExecutionView.tsx +2 -3
- package/src/components/execution/execution-definition.test.ts +19 -0
- package/src/components/execution/execution-definition.ts +2 -0
- package/src/components/gitops/GitOpsView.tsx +25 -3
- package/src/components/home/CostCard.tsx +3 -2
- package/src/components/nav/navigation.test.ts +26 -0
- package/src/components/nav/navigation.ts +10 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
- package/src/components/rightsizing/copy.test.ts +19 -0
- package/src/components/settings/SettingsDialog.tsx +666 -103
- package/src/components/settings/settings-state.test.ts +42 -0
- package/src/components/settings/settings-state.ts +39 -0
- package/src/index.css +11 -1
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
initNavigationMap,
|
|
26
26
|
kindToPluralWithGroup,
|
|
27
27
|
normalizeArgoApplication,
|
|
28
|
+
normalizeArgoApplicationSet,
|
|
28
29
|
normalizeFluxHelmRelease,
|
|
29
30
|
normalizeFluxKustomization,
|
|
30
31
|
parseArgoRollbackID,
|
|
@@ -159,6 +160,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
159
160
|
|
|
160
161
|
const hasGitOpsRowResource = useMemo(() => (
|
|
161
162
|
hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
|
|
163
|
+
hasAPIResource(apiResources, 'applicationsets', 'argoproj.io') ||
|
|
162
164
|
hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io') ||
|
|
163
165
|
hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
|
|
164
166
|
), [apiResources])
|
|
@@ -186,6 +188,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
186
188
|
queryKey: ['gitops-rows-main', namespaces, apiResources?.length ?? 0],
|
|
187
189
|
queryFn: async () => {
|
|
188
190
|
const hasApplications = hasAPIResource(apiResources, 'applications', 'argoproj.io')
|
|
191
|
+
const hasApplicationSets = hasAPIResource(apiResources, 'applicationsets', 'argoproj.io')
|
|
189
192
|
const hasKustomizations = hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io')
|
|
190
193
|
const hasHelmReleases = hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
|
|
191
194
|
const hasFluxSources = hasKustomizations || hasHelmReleases
|
|
@@ -193,8 +196,9 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
193
196
|
const hasHelmRepos = hasFluxSources && hasAPIResource(apiResources, 'helmrepositories', 'source.toolkit.fluxcd.io')
|
|
194
197
|
const hasOCIRepos = hasFluxSources && hasAPIResource(apiResources, 'ocirepositories', 'source.toolkit.fluxcd.io')
|
|
195
198
|
const hasBuckets = hasFluxSources && hasAPIResource(apiResources, 'buckets', 'source.toolkit.fluxcd.io')
|
|
196
|
-
const [applications, kustomizations, helmReleases, gitRepos, helmRepos, ociRepos, buckets] = await Promise.all([
|
|
199
|
+
const [applications, applicationSets, kustomizations, helmReleases, gitRepos, helmRepos, ociRepos, buckets] = await Promise.all([
|
|
197
200
|
hasApplications ? fetchResourceList('applications', 'argoproj.io', namespacesParam) : Promise.resolve([]),
|
|
201
|
+
hasApplicationSets ? fetchResourceList('applicationsets', 'argoproj.io', namespacesParam) : Promise.resolve([]),
|
|
198
202
|
hasKustomizations ? fetchResourceList('kustomizations', 'kustomize.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
|
|
199
203
|
hasHelmReleases ? fetchResourceList('helmreleases', 'helm.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
|
|
200
204
|
hasGitRepos ? fetchResourceList('gitrepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
|
|
@@ -205,6 +209,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
205
209
|
const fluxSourceUrls = buildFluxSourceUrlMap([...gitRepos, ...helmRepos, ...ociRepos, ...buckets])
|
|
206
210
|
return [
|
|
207
211
|
...applications.map((r) => normalizeArgoApplication(r)),
|
|
212
|
+
...applicationSets.map((r) => normalizeArgoApplicationSet(r)),
|
|
208
213
|
...kustomizations.map((r) => normalizeFluxKustomization(r, fluxSourceUrls)),
|
|
209
214
|
...helmReleases.map((r) => normalizeFluxHelmRelease(r, fluxSourceUrls)),
|
|
210
215
|
]
|
|
@@ -462,8 +467,17 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
462
467
|
// The tree's root node is this page's own subject — clicking it must not
|
|
463
468
|
// open a nested copy of the same detail page (which stacks an identical
|
|
464
469
|
// "GitOps / X / X" breadcrumb, and again, ad infinitum). A self-reference
|
|
465
|
-
//
|
|
470
|
+
// opens the standard resource drawer instead, matching how every other
|
|
471
|
+
// resource in Radar responds to a click — the header already represents
|
|
472
|
+
// this resource, so a full navigation would be redundant, but a raw
|
|
473
|
+
// metadata/YAML drawer is not.
|
|
466
474
|
if (detailKind === kind && (ref.namespace || '') === (namespace || '') && ref.name === name) {
|
|
475
|
+
onOpenResource({
|
|
476
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
|
|
477
|
+
namespace: ref.namespace || '',
|
|
478
|
+
name: ref.name,
|
|
479
|
+
group: ref.group,
|
|
480
|
+
})
|
|
467
481
|
return
|
|
468
482
|
}
|
|
469
483
|
const params = new URLSearchParams()
|
|
@@ -555,12 +569,19 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
555
569
|
// The bulk of the JSX is now in <GitOpsDetailLayout>; this wrapper does
|
|
556
570
|
// the OSS-specific things the layout can't (call OSS-side data hooks,
|
|
557
571
|
// open OSS dialogs, talk to OSS Toast, hit OSS keyboard registry).
|
|
572
|
+
const isApplicationSet = kind === 'applicationsets'
|
|
558
573
|
const detail: GitOpsDetailMetadata = {
|
|
559
574
|
project: detailRow?.project,
|
|
560
575
|
repository: detailRow?.repository ? formatGitOpsSourceUrl(detailRow.repository) : undefined,
|
|
561
576
|
path: detailRow?.path || undefined,
|
|
562
577
|
chart: detailRow?.chart || undefined,
|
|
563
|
-
|
|
578
|
+
// An empty destination means "in-cluster" for an Application - that is
|
|
579
|
+
// Argo's default when the field is unset. For an ApplicationSet it means
|
|
580
|
+
// the template's destination was a generator placeholder, which is not the
|
|
581
|
+
// same claim, so the server half is dropped rather than asserted.
|
|
582
|
+
destination: isApplicationSet && !detailRow?.destination
|
|
583
|
+
? (detailRow?.destinationNamespace ? `Namespace: ${detailRow.destinationNamespace}` : '')
|
|
584
|
+
: formatGitOpsDestination(detailRow?.destination, detailRow?.destinationNamespace),
|
|
564
585
|
autoSyncMode: insightsQ.data?.summary?.autoSyncMode,
|
|
565
586
|
}
|
|
566
587
|
|
|
@@ -920,6 +941,7 @@ function tryParseYaml(value: string): unknown {
|
|
|
920
941
|
|
|
921
942
|
function normalizeDetailResource(kind: string, group: string, resource: any): GitOpsRow | null {
|
|
922
943
|
if (kind === 'applications') return normalizeArgoApplication(resource)
|
|
944
|
+
if (kind === 'applicationsets') return normalizeArgoApplicationSet(resource)
|
|
923
945
|
if (kind === 'kustomizations') return normalizeFluxKustomization(resource)
|
|
924
946
|
if (kind === 'helmreleases') return normalizeFluxHelmRelease(resource)
|
|
925
947
|
const status = getGitOpsResourceStatus(kind, resource)
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
formatProjectedMonthlyCost,
|
|
9
9
|
formatProjectedMonthlyRate,
|
|
10
10
|
} from '../cost/format'
|
|
11
|
+
import { costFreshnessLabel } from '../cost/source'
|
|
11
12
|
|
|
12
13
|
export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
|
|
13
14
|
const { data } = useOpenCostSummary()
|
|
@@ -87,13 +88,13 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
|
|
|
87
88
|
|
|
88
89
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
|
|
89
90
|
<span className="text-[10px] text-theme-text-tertiary">
|
|
90
|
-
{currency} · projected
|
|
91
|
+
{currency} · projected from {costFreshnessLabel(data.source, data.window, data.dataThrough)}
|
|
91
92
|
{currency !== DEFAULT_COST_CURRENCY && (
|
|
92
93
|
<> · no conversion</>
|
|
93
94
|
)}
|
|
94
95
|
</span>
|
|
95
96
|
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
|
96
|
-
OpenCost
|
|
97
|
+
{data.source === 'kubecost' ? 'Kubecost' : 'OpenCost'}
|
|
97
98
|
</span>
|
|
98
99
|
</div>
|
|
99
100
|
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { navigateFromPrimaryRail } from './navigation'
|
|
3
|
+
|
|
4
|
+
describe('navigateFromPrimaryRail', () => {
|
|
5
|
+
it('closes an expanded investigation before showing the selected destination', () => {
|
|
6
|
+
const calls: string[] = []
|
|
7
|
+
|
|
8
|
+
navigateFromPrimaryRail(
|
|
9
|
+
true,
|
|
10
|
+
() => calls.push('close'),
|
|
11
|
+
() => calls.push('navigate'),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
expect(calls).toEqual(['close', 'navigate'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('keeps a docked investigation open across sidebar navigation', () => {
|
|
18
|
+
const close = vi.fn()
|
|
19
|
+
const navigate = vi.fn()
|
|
20
|
+
|
|
21
|
+
navigateFromPrimaryRail(false, close, navigate)
|
|
22
|
+
|
|
23
|
+
expect(close).not.toHaveBeenCalled()
|
|
24
|
+
expect(navigate).toHaveBeenCalledOnce()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Sidebar navigation means “show me that destination.” An expanded investigation
|
|
2
|
+
// obscures it, while a docked investigation intentionally stays alongside it.
|
|
3
|
+
export function navigateFromPrimaryRail(
|
|
4
|
+
expandedInvestigationOpen: boolean,
|
|
5
|
+
closeDiagnose: () => void,
|
|
6
|
+
navigate: () => void,
|
|
7
|
+
) {
|
|
8
|
+
if (expandedInvestigationOpen) closeDiagnose()
|
|
9
|
+
navigate()
|
|
10
|
+
}
|
|
@@ -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({
|