diff --git a/backend/src/app.ts b/backend/src/app.ts index 975c0a35b69..99795f9fb16 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,7 @@ import { operatorCheck } from './routes/operatorCheck' import { proxy } from './routes/proxy' import { readiness } from './routes/readiness' import { search } from './routes/search' +import { placementDebug } from './routes/placementDebug' import { serveHandler } from './routes/serve' import { upgradeRiskPredictions } from './routes/upgrade-risks-prediction' import { username } from './routes/username' @@ -66,6 +67,7 @@ if (eventsEnabled) { router.get('/events', events) } router.post('/proxy/search', search) +router.post('/placement-debug', placementDebug) router.get('/authenticated', authenticated) router.post('/ansibletower', ansibleTower) router.get('/username', username) diff --git a/backend/src/routes/placementDebug.ts b/backend/src/routes/placementDebug.ts new file mode 100644 index 00000000000..2434186b9ef --- /dev/null +++ b/backend/src/routes/placementDebug.ts @@ -0,0 +1,95 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import type { Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'node:http2' +import { constants } from 'node:http2' +import type { RequestOptions } from 'node:https' +import { request } from 'node:https' +import { URL } from 'node:url' +import { getServiceAgent } from '../lib/agent' +import { logger } from '../lib/logger' +import { respondInternalServerError } from '../lib/respond' +import { getAuthenticatedToken } from '../lib/token' + +const proxyHeaders = [ + constants.HTTP2_HEADER_ACCEPT, + constants.HTTP2_HEADER_ACCEPT_ENCODING, + constants.HTTP2_HEADER_CONTENT_ENCODING, + constants.HTTP2_HEADER_CONTENT_LENGTH, + constants.HTTP2_HEADER_CONTENT_TYPE, +] + +const defaultServiceHost = 'cluster-manager-placement.open-cluster-management-hub.svc.cluster.local' +const defaultPlacementDebugUrl = `https://${defaultServiceHost}:9443/debug/placements/` + +const MAX_BODY_SIZE = 1024 * 1024 // 1MB + +function collectBody(req: Http2ServerRequest): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > MAX_BODY_SIZE) { + req.destroy() + reject(new Error('Request body too large')) + return + } + chunks.push(chunk) + }) + req.on('end', () => resolve(Buffer.concat(chunks))) + req.on('error', reject) + }) +} + +export async function placementDebug(req: Http2ServerRequest, res: Http2ServerResponse): Promise { + const token = await getAuthenticatedToken(req, res) + if (!token) return + + let body: Buffer + try { + body = await collectBody(req) + } catch (err) { + if (!res.headersSent) { + res.writeHead(413, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: 'Request body too large' })) + } + return + } + + const headers: OutgoingHttpHeaders = { + authorization: `Bearer ${token}`, + } + for (const header of proxyHeaders) { + if (req.headers[header]) headers[header] = req.headers[header] + } + headers['content-type'] = 'application/json' + headers['content-length'] = body.length + + const url = new URL(process.env.PLACEMENT_DEBUG_URL || defaultPlacementDebugUrl) + headers.host = url.hostname + + const options: RequestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers, + agent: getServiceAgent(), + } + + const upstream = request(options, (response) => { + if (!response) return respondInternalServerError(req, res) + res.writeHead(response.statusCode ?? 500, { + 'content-type': 'application/json', + }) + response.pipe(res as unknown as NodeJS.WritableStream) + }) + + upstream.on('error', (err) => { + logger.error({ msg: 'placement debug upstream error', error: err.message }) + if (!res.headersSent) respondInternalServerError(req, res) + }) + + upstream.write(body) + upstream.end() +} diff --git a/backend/test/routes/placementDebug.test.ts b/backend/test/routes/placementDebug.test.ts new file mode 100644 index 00000000000..0d466fb376a --- /dev/null +++ b/backend/test/routes/placementDebug.test.ts @@ -0,0 +1,62 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { request } from '../mock-request' +import nock from 'nock' + +const upstreamHost = 'https://cluster-manager-placement.open-cluster-management-hub.svc.cluster.local:9443' + +function nockAuth(status = 200) { + nock(process.env.CLUSTER_API_URL).get('/apis').reply(status, { status }) +} + +describe(`placementDebug Route`, function () { + it(`proxies placement debug request to upstream service`, async function () { + nockAuth() + nock(upstreamHost).post('/debug/placements/').reply(200, { aggregatedScores: [] }) + const res = await request('POST', '/placement-debug', { placement: 'test' }) + expect(res.statusCode).toEqual(200) + }) + + it(`handles upstream errors`, async function () { + nockAuth() + nock(upstreamHost).post('/debug/placements/').reply(500, { error: 'internal server error' }) + const res = await request('POST', '/placement-debug', { placement: 'test' }) + expect(res.statusCode).toEqual(500) + }) + + it(`rejects unauthenticated requests`, async function () { + nockAuth(401) + const res = await request('POST', '/placement-debug', { placement: 'test' }) + expect(res.statusCode).toEqual(401) + }) + + it(`uses dev agent when PLACEMENT_DEBUG_URL is set`, async function () { + const original = process.env.PLACEMENT_DEBUG_URL + process.env.PLACEMENT_DEBUG_URL = 'https://localhost:9443/debug/placements/' + try { + nockAuth() + nock('https://localhost:9443').post('/debug/placements/').reply(200, { aggregatedScores: [] }) + const res = await request('POST', '/placement-debug', { placement: 'test' }) + expect(res.statusCode).toEqual(200) + } finally { + if (original === undefined) { + delete process.env.PLACEMENT_DEBUG_URL + } else { + process.env.PLACEMENT_DEBUG_URL = original + } + } + }) + + it(`handles upstream connection errors`, async function () { + nockAuth() + nock(upstreamHost).post('/debug/placements/').replyWithError('ECONNREFUSED') + const res = await request('POST', '/placement-debug', { placement: 'test' }) + expect(res.statusCode).toEqual(500) + }) + + it(`rejects oversized request body`, async function () { + nockAuth() + const largeBody = { data: 'x'.repeat(1024 * 1024 + 1) } + const res = await request('POST', '/placement-debug', largeBody) + expect(res.statusCode).toEqual(413) + }) +}) diff --git a/frontend/packages/react-form-wizard/src/Wizard.tsx b/frontend/packages/react-form-wizard/src/Wizard.tsx index 98e9d6d8f34..8a859d78da2 100644 --- a/frontend/packages/react-form-wizard/src/Wizard.tsx +++ b/frontend/packages/react-form-wizard/src/Wizard.tsx @@ -48,6 +48,7 @@ import { useStepShowValidation, } from './contexts/StepShowValidationProvider' import { StepValidationProvider, useStepHasValidationError } from './contexts/StepValidationProvider' +import { FooterContentProvider, useFooterContent } from './contexts/FooterContentProvider' import { defaultStrings, StringContext, useStringContext, WizardStrings } from './contexts/StringContext' import { EditorValidationStatus, @@ -107,29 +108,31 @@ export function Wizard(props: WizardProps & { showHeader?: boolean; showYaml?: b - - }> - - - - - {props.children} - - - - - - + + + }> + + + + + {props.children} + + + + + + + @@ -344,6 +347,8 @@ function MyFooter(props: WizardFooterProps) { nextButtonText, } = useStringContext() + const footerContent = useFooterContent() + if (isLastStep) { return (
@@ -405,6 +410,7 @@ function MyFooter(props: WizardFooterProps) { )} + {footerContent} diff --git a/frontend/packages/react-form-wizard/src/contexts/DisplayModeContext.tsx b/frontend/packages/react-form-wizard/src/contexts/DisplayModeContext.tsx index 7cf7769015f..066e04d0388 100644 --- a/frontend/packages/react-form-wizard/src/contexts/DisplayModeContext.tsx +++ b/frontend/packages/react-form-wizard/src/contexts/DisplayModeContext.tsx @@ -4,6 +4,7 @@ import { createContext, useContext } from 'react' export enum DisplayMode { Step, StepsHidden, + Details, } export const DisplayModeContext = createContext(DisplayMode.Step) diff --git a/frontend/packages/react-form-wizard/src/contexts/FooterContentProvider.tsx b/frontend/packages/react-form-wizard/src/contexts/FooterContentProvider.tsx new file mode 100644 index 00000000000..91f0ce02dae --- /dev/null +++ b/frontend/packages/react-form-wizard/src/contexts/FooterContentProvider.tsx @@ -0,0 +1,24 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { createContext, ReactNode, useCallback, useContext, useState } from 'react' + +const FooterContentContext = createContext(undefined) +FooterContentContext.displayName = 'FooterContentContext' + +const SetFooterContentContext = createContext<(content: ReactNode) => void>(() => null) +SetFooterContentContext.displayName = 'SetFooterContentContext' + +export const useFooterContent = () => useContext(FooterContentContext) +export const useSetFooterContent = () => useContext(SetFooterContentContext) + +export function FooterContentProvider(props: { children: ReactNode }) { + const [footerContent, setFooterContentState] = useState(undefined) + const setFooterContent = useCallback((content: ReactNode) => { + setFooterContentState(content) + }, []) + + return ( + + {props.children} + + ) +} diff --git a/frontend/packages/react-form-wizard/src/index.ts b/frontend/packages/react-form-wizard/src/index.ts index 8c07a4533af..f5a73b08e34 100644 --- a/frontend/packages/react-form-wizard/src/index.ts +++ b/frontend/packages/react-form-wizard/src/index.ts @@ -7,6 +7,7 @@ export * from './contexts/HasInputsProvider' export * from './contexts/HasValueProvider' export * from './contexts/ItemContext' export * from './contexts/DisplayModeContext' +export * from './contexts/FooterContentProvider' export * from './contexts/ValidationProvider' export * from './contexts/StringContext' export * from './review/ReviewStepContexts' diff --git a/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx b/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx index 6f892e14516..bf795769368 100644 --- a/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx +++ b/frontend/packages/react-form-wizard/src/inputs/WizCustomWrapper.tsx @@ -26,6 +26,10 @@ export type WizCustomWrapperInputProps = WizCustomWrapperBase & { id?: string label?: string value: ReactNode + /** When true, the review row omits the edit pen — used for computed / read-only values. */ + nonEditable?: boolean + /** When set, the review row renders as a PatternFly Alert instead of a description-list entry. */ + alertVariant?: 'info' | 'warning' | 'danger' | 'success' inputValueToPathValue?: (inputValue: unknown, pathValue: unknown) => unknown } @@ -42,6 +46,8 @@ export function WizCustomWrapper(props: WizCustomWrapperProps) { const isGroup = props.type === InputReviewMeta.GROUP const { path, id: idProp, label, children } = props const value = isGroup ? undefined : props.value + const nonEditable = isGroup ? undefined : props.nonEditable + const alertVariant = isGroup ? undefined : props.alertVariant const inputValueToPathValue = isGroup ? undefined : props.inputValueToPathValue const hidden = useInputHidden(props) @@ -82,11 +88,25 @@ export function WizCustomWrapper(props: WizCustomWrapperProps) { label, error: undefined, type: InputReviewMeta.INPUT, + nonEditable, + alertVariant, }) } bumpReviewDomTree?.() return () => stepInputsRegistry.unregister(id) - }, [stepInputsRegistry, currentStepId, hidden, id, registrationPath, value, label, bumpReviewDomTree, isGroup]) + }, [ + stepInputsRegistry, + currentStepId, + hidden, + id, + registrationPath, + value, + label, + nonEditable, + alertVariant, + bumpReviewDomTree, + isGroup, + ]) return
{children}
} diff --git a/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx b/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx index 3eb21086e2f..405d03439f3 100644 --- a/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx +++ b/frontend/packages/react-form-wizard/src/inputs/WizLabelSelect.tsx @@ -1,6 +1,15 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { Label, MenuToggle, MenuToggleElement, Select as PfSelect } from '@patternfly/react-core' +import { + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Label, + MenuToggle, + MenuToggleElement, + Select as PfSelect, +} from '@patternfly/react-core' import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { DisplayMode } from '../contexts/DisplayModeContext' import { useStringContext } from '../contexts/StringContext' import { getSelectPlaceholder, InputCommonProps, useInput } from './Input' import { InputSelect, SelectListOptions } from './InputSelect' @@ -28,9 +37,9 @@ export type WizLabelSelectProps = InputCommonProps & { * When a value is selected, shows a simple toggle with the Label pill. */ export function WizLabelSelect(props: WizLabelSelectProps) { - const { value, setValue, validated, hidden, id, disabled, required } = useInput(props) + const { displayMode: mode, value, setValue, validated, hidden, id, disabled, required } = useInput(props) const { noResults } = useStringContext() - const { readonly, isCreatable, footer } = props + const { label, readonly, isCreatable, footer } = props const placeholder = getSelectPlaceholder(props) const [open, setOpen] = useState(false) const [filteredOptions, setFilteredOptions] = useState([]) @@ -100,6 +109,18 @@ export function WizLabelSelect(props: WizLabelSelectProps) { if (hidden) return null + if (mode === DisplayMode.Details) { + if (!value) return null + return ( + + {label} + + + + + ) + } + const toggle = (toggleRef: React.Ref) => value ? ( { if (inputNode != null && onReviewEdit != null) { @@ -876,7 +878,7 @@ function renderReviewInputRows(nodes: readonly WizardInputDomNode[], ctx: Review const yamlVisible = ctx.showYaml !== false return ( - {onReviewEdit != null ? ( + {onReviewEdit != null && !inputNode.nonEditable ? ( + ) + precedingDlGroup = false + i++ + continue + } const run: WizardInputDomNode[] = [] - while (i < nodes.length && isReviewInputNode(nodes[i]!)) { + while (i < nodes.length && isReviewInputNode(nodes[i]!) && !(nodes[i] as WizardInputDomNode).alertVariant) { run.push(nodes[i] as WizardInputDomNode) i++ } diff --git a/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx b/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx index ead50d48d35..a399a430a3d 100644 --- a/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx +++ b/frontend/packages/react-form-wizard/src/review/ReviewStepContexts.tsx @@ -29,6 +29,10 @@ export type InputReviewStepMeta = type: InputReviewMeta.INPUT /** Nearest enclosing wizard step `id` (set when building the review DOM tree). */ stepId?: string + /** When true, the review row omits the edit pen — used for computed / read-only values. */ + nonEditable?: boolean + /** When set, the review row renders as a PatternFly Alert instead of a description-list entry. */ + alertVariant?: 'info' | 'warning' | 'danger' | 'success' } | { id: string @@ -75,6 +79,10 @@ export type WizardDomTreeNode = | (Omit & { type: InputReviewMeta.INPUT stepId: string + /** When true, the review row omits the edit pen — used for computed / read-only values. */ + nonEditable?: boolean + /** When set, the review row renders as a PatternFly Alert instead of a description-list entry. */ + alertVariant?: 'info' | 'warning' | 'danger' | 'success' children?: WizardDomTreeNode[] }) | (Omit & { type: InputReviewMeta.ARRAY_INPUT; children?: WizardDomTreeNode[] }) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index cbaf6a54e2d..cd40751c3ce 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -29,6 +29,13 @@ "{{count}} selected": "{{count}} selected", "{{count}} selected_plural": "{{count}} selected", "{{kind}} details": "{{kind}} details", + "{{matched}} cluster matched": "{{matched}} cluster matched", + "{{matched}} clusters matched": "{{matched}} clusters matched", + "{{matched}} of {{total}} cluster": "{{matched}} of {{total}} cluster", + "{{matched}} of {{total}} cluster matched": "{{matched}} of {{total}} cluster matched", + "{{matched}} of {{total}} clusters": "{{matched}} of {{total}} clusters", + "{{matched}} of {{total}} clusters matched": "{{matched}} of {{total}} clusters matched", + "{{matched}} of {{total}} clusters matched by placement": "{{matched}} of {{total}} clusters matched by placement", "{{name}} group has been successfully added.": "{{name}} group has been successfully added.", "{{name}} group has been successfully created.": "{{name}} group has been successfully created.", "{{name}} has been updated.": "{{name}} has been updated.", @@ -800,6 +807,7 @@ "clusterPools": "Cluster pools", "Clusters": "Clusters", "clusters affected": "clusters affected", + "Clusters currently targeted for deployment.": "Clusters currently targeted for deployment.", "clusters need to be reviewed before updating": "clusters need to be reviewed before updating", "Clusters with pending policies": "Clusters with pending policies", "Clusters with policy violations": "Clusters with policy violations", @@ -1889,6 +1897,7 @@ "Kubernetes version": "Kubernetes version", "Label": "Label", "Label expressions": "Label expressions", + "Label expressions and tolerations": "Label expressions and tolerations", "Label selectors": "Label selectors", "Labels": "Labels", "Labels to match clusters by": "Labels to match clusters by", @@ -1929,6 +1938,7 @@ "Leave empty for all effects": "Leave empty for all effects", "Leave form?": "Leave form?", "Let’s get started.": "Let’s get started.", + "Limit the number of clusters selected": "Limit the number of clusters selected", "Limits": "Limits", "List elements": "List elements", "Loading": "Loading", @@ -2034,6 +2044,8 @@ "Map infrastructure volume snapshot classes to guest cluster volume snapshot classes. These mappings cannot be changed after cluster creation.": "Map infrastructure volume snapshot classes to guest cluster volume snapshot classes. These mappings cannot be changed after cluster creation.", "Match clusters using label selectors. Multiple expressions are combined using AND logic (all inputs must be true).": "Match clusters using label selectors. Multiple expressions are combined using AND logic (all inputs must be true).", "Match labels": "Match labels", + "Matched": "Matched", + "Matched by Placement": "Matched by Placement", "Matched Clusters": "Matched Clusters", "Matched on": "Matched on", "Maximum replicas must be greater than or equal to minimum replicas.": "Maximum replicas must be greater than or equal to minimum replicas.", @@ -2132,7 +2144,9 @@ "No clusters are reporting status for this policy.": "No clusters are reporting status for this policy.", "No clusters available": "No clusters available", "No clusters found": "No clusters found", + "No clusters found matching \"{{search}}\"": "No clusters found matching \"{{search}}\"", "No clusters in this cluster set have the Submariner add-on installed. To get started, install Submariner add-ons to install the add-on on any available clusters in this cluster set.": "No clusters in this cluster set have the Submariner add-on installed. To get started, install Submariner add-ons to install the add-on on any available clusters in this cluster set.", + "No clusters match the current placement criteria. To identify available clusters, check your label expressions, tolerations, or limits.": "No clusters match the current placement criteria. To identify available clusters, check your label expressions, tolerations, or limits.", "No clusters selection to create projects for": "No clusters selection to create projects for", "No common projects found": "No common projects found", "No conditions": "No conditions", @@ -2230,13 +2244,13 @@ "Not found": "Not found", "Not implemented": "Not implemented", "Not mapped": "Not mapped", + "Not matched": "Not matched", "Not ready": "Not ready", "Not selected": "Not selected", "Not selected by placement": "Not selected by placement", "Not set": "Not set", "Note: Resources that you do not have permission to view display a status of \"Not deployed\".": "Note: Resources that you do not have permission to view display a status of \"Not deployed\".", "Number input": "Number input", - "Number of clusters": "Number of clusters", "Number of clusters where the grouped Argo applications' resources are deployed.": "Number of clusters where the grouped Argo applications' resources are deployed.", "Number of control plane nodes": "Number of control plane nodes", "Number of nodes": "Number of nodes", @@ -2780,6 +2794,7 @@ "Show workloads running on your fleet": "Show workloads running on your fleet", "show.more": "{{count}} more", "show.more_plural": "{{count}} more", + "Showing clusters that match your defined labels, tolerations, and limits.": "Showing clusters that match your defined labels, tolerations, and limits.", "Simple Table": "Simple Table", "Single cluster": "Single cluster", "Single node OpenShift": "Single node OpenShift", @@ -3258,6 +3273,7 @@ "There is no history for the policy template on this cluster.": "There is no history for the policy template on this cluster.", "There is not enough information in the subscription to retrieve deployed objects data.": "There is not enough information in the subscription to retrieve deployed objects data.", "there.were.errors": "There were errors processing the requests", + "These clusters match your label expressions and tolerations, but are not currently assigned due to placement limit or prioritization.": "These clusters match your label expressions and tolerations, but are not currently assigned due to placement limit or prioritization.", "These examples show different ways to scope role assignments.": "These examples show different ways to scope role assignments.", "This action removes the automation template from the following list of clusters. Only clusters that have an automation template are listed.": "This action removes the automation template from the following list of clusters. Only clusters that have an automation template are listed.", "This application has no matched subscription. Make sure the subscription match selector spec.selector.matchExpressions exists and matches a Subscription resource created in the {{0}} namespace.": "This application has no matched subscription. Make sure the subscription match selector spec.selector.matchExpressions exists and matches a Subscription resource created in the {{0}} namespace.", @@ -3454,6 +3470,7 @@ "Unable to communicate with the server because the network connection was reset.": "Unable to communicate with the server because the network connection was reset.", "Unable to communicate with the server because the service is unavailable.": "Unable to communicate with the server because the service is unavailable.", "Unable to communicate with the server due to a gateway timeout.": "Unable to communicate with the server due to a gateway timeout.", + "Unable to determine cluster matches.": "Unable to determine cluster matches.", "Unable to update the resource because of a resource conflict.": "Unable to update the resource because of a resource conflict.", "Unauthorized": "Unauthorized", "unavailable": "unavailable", diff --git a/frontend/src/resources/managed-cluster.ts b/frontend/src/resources/managed-cluster.ts index 2a7bb89f2b8..9084ff7d74f 100644 --- a/frontend/src/resources/managed-cluster.ts +++ b/frontend/src/resources/managed-cluster.ts @@ -23,6 +23,7 @@ export interface ManagedCluster extends IResource { hubAcceptsClient: boolean leaseDurationSeconds?: number managedClusterClientConfigs?: any[] + taints?: ManagedClusterTaint[] } status?: { allocatable: { @@ -41,6 +42,13 @@ export interface ManagedCluster extends IResource { } } +export interface ManagedClusterTaint { + key: string + value?: string + effect: 'NoSelect' | 'PreferNoSelect' | 'NoSelectIfNew' + timeAdded?: string +} + export const createManagedCluster = (data: { clusterName: string | undefined clusterLabels: Record diff --git a/frontend/src/resources/placement-debug.test.ts b/frontend/src/resources/placement-debug.test.ts new file mode 100644 index 00000000000..32bffbb339f --- /dev/null +++ b/frontend/src/resources/placement-debug.test.ts @@ -0,0 +1,54 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { postPlacementDebug } from './placement-debug' +import { IPlacement } from '../wizards/common/resources/IPlacement' + +jest.mock('./utils/resource-request', () => ({ + postRequest: jest.fn((_url: string, body: unknown) => ({ + promise: Promise.resolve(body), + abort: jest.fn(), + })), + getBackendUrl: jest.fn(() => 'https://localhost'), +})) + +const { postRequest } = jest.requireMock('./utils/resource-request') as { postRequest: jest.Mock } + +describe('postPlacementDebug', () => { + it('sends placement metadata and spec to backend', () => { + const placement: IPlacement = { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: 'Placement', + metadata: { name: 'test', namespace: 'default' }, + spec: { clusterSets: ['my-set'] }, + } + + const result = postPlacementDebug(placement) + + expect(postRequest).toHaveBeenCalledWith('https://localhost/placement-debug', { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: 'Placement', + metadata: { name: 'test', namespace: 'default' }, + spec: { clusterSets: ['my-set'] }, + }) + expect(result).toHaveProperty('promise') + expect(result).toHaveProperty('abort') + }) + + it('only sends apiVersion, kind, metadata, and spec', () => { + const placement = { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: 'Placement', + metadata: { name: 'test', namespace: 'default', uid: 'abc', resourceVersion: '1' }, + spec: {}, + status: { conditions: [{ type: 'Satisfied' }] }, + } as IPlacement + + postPlacementDebug(placement) + + const body = postRequest.mock.calls[postRequest.mock.calls.length - 1][1] + expect(body).toHaveProperty('apiVersion') + expect(body).toHaveProperty('kind') + expect(body).toHaveProperty('metadata') + expect(body).toHaveProperty('spec') + expect(body).not.toHaveProperty('status') + }) +}) diff --git a/frontend/src/resources/placement-debug.ts b/frontend/src/resources/placement-debug.ts new file mode 100644 index 00000000000..ade4326d46a --- /dev/null +++ b/frontend/src/resources/placement-debug.ts @@ -0,0 +1,31 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { IPlacement, PlacementApiVersion, PlacementKind } from '../wizards/common/resources/IPlacement' +import { IRequestResult, postRequest } from './utils/resource-request' +import { getBackendUrl } from './utils/resource-request' + +interface ClusterScore { + clusterName: string + score: number +} + +export interface PlacementDebugResult { + placement?: IPlacement + filteredPipelineResults?: Array<{ + name: string + filteredClusters: string[] + }> + prioritizeResults?: unknown + aggregatedScores?: ClusterScore[] + error?: string +} + +export function postPlacementDebug(placement: IPlacement): IRequestResult { + const url = getBackendUrl() + '/placement-debug' + const body = { + apiVersion: PlacementApiVersion, + kind: PlacementKind, + metadata: placement.metadata, + spec: placement.spec, + } + return postRequest(url, body) +} diff --git a/frontend/src/wizards/Argo/ArgoWizard.tsx b/frontend/src/wizards/Argo/ArgoWizard.tsx index 615a4c44920..361bd9e9fa6 100644 --- a/frontend/src/wizards/Argo/ArgoWizard.tsx +++ b/frontend/src/wizards/Argo/ArgoWizard.tsx @@ -30,7 +30,7 @@ import { useValidation } from '../../hooks/useValidation' import { useWizardStrings } from '../../lib/wizardStrings' import { NavigationPath } from '../../NavigationPath' import { ApplicationSetKind, GitOpsCluster, Secret } from '../../resources' -import { useSharedSelectors } from '../../shared-recoil' +import { useRecoilValue, useSharedAtoms, useSharedSelectors } from '../../shared-recoil' import { IClusterSetBinding } from '../common/resources/IClusterSetBinding' import { IPlacement, PlacementApiVersion, PlacementKind, PlacementType } from '../common/resources/IPlacement' import { IResource } from '../common/resources/IResource' @@ -871,6 +871,8 @@ function ArgoWizardPlacementSection(props: { isPullModel?: boolean }) { const { t } = useTranslation() + const { settingsState } = useSharedAtoms() + const settings = useRecoilValue(settingsState) const resources = useItem() as IResource[] const editMode = useEditMode() const hasPlacement = resources.find((r) => r.kind === PlacementKind) !== undefined @@ -991,6 +993,7 @@ function ArgoWizardPlacementSection(props: { {t('Add cluster set')} } + showPlacementPreview={settings.enhancedPlacement === 'enabled'} /> ) : ( diff --git a/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx b/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx index 9c8e710bd52..64657d2b452 100644 --- a/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx +++ b/frontend/src/wizards/Governance/Policy/policyWizard.test.tsx @@ -12,6 +12,7 @@ import { import { IResource } from '@patternfly-labs/react-form-wizard' import { ReactNode } from 'react' import { BrowserRouter as Router } from 'react-router-dom-v5-compat' +import { RecoilRoot } from 'recoil' import { waitForText } from '../../../lib/test-util' import { Policy } from '../../../resources' import { WizardSyncEditor } from '../../../routes/Governance/policies/CreatePolicy' @@ -31,20 +32,22 @@ describe('ExistingTemplateName', () => { function TestPolicyWizard(props?: { yamlEditor?: () => ReactNode }) { return ( - - new Promise(() => {})} - onCancel={() => {}} - yamlEditor={props?.yamlEditor} - /> - + + + new Promise(() => {})} + onCancel={() => {}} + yamlEditor={props?.yamlEditor} + /> + + ) } @@ -70,20 +73,22 @@ function TestPolicyWizardGK() { ] return ( - - new Promise(() => {})} - onCancel={() => {}} - resources={[mockPolicyGK as IResource]} - /> - + + + new Promise(() => {})} + onCancel={() => {}} + resources={[mockPolicyGK as IResource]} + /> + + ) } @@ -101,21 +106,23 @@ function TestPolicyWizardOperatorPolicy() { ] return ( - - new Promise(() => {})} - onCancel={() => {}} - resources={[mockPolicyOperatorPlc as IResource]} - yamlEditor={() => } - /> - + + + new Promise(() => {})} + onCancel={() => {}} + resources={[mockPolicyOperatorPlc as IResource]} + yamlEditor={() => } + /> + + ) } diff --git a/frontend/src/wizards/Governance/PolicySet/PolicySetWizard.test.tsx b/frontend/src/wizards/Governance/PolicySet/PolicySetWizard.test.tsx index bb996bf30bb..9f6beb5efb1 100644 --- a/frontend/src/wizards/Governance/PolicySet/PolicySetWizard.test.tsx +++ b/frontend/src/wizards/Governance/PolicySet/PolicySetWizard.test.tsx @@ -11,22 +11,25 @@ import { import { PolicySetWizard } from './PolicySetWizard' import { IResource } from '@patternfly-labs/react-form-wizard' import { BrowserRouter as Router } from 'react-router-dom-v5-compat' +import { RecoilRoot } from 'recoil' function TestPolicySetWizard() { return ( - - new Promise(() => {})} - onCancel={() => {}} - /> - + + + new Promise(() => {})} + onCancel={() => {}} + /> + + ) } diff --git a/frontend/src/wizards/Placement/MatchedClustersModal.test.tsx b/frontend/src/wizards/Placement/MatchedClustersModal.test.tsx new file mode 100644 index 00000000000..d2823616f38 --- /dev/null +++ b/frontend/src/wizards/Placement/MatchedClustersModal.test.tsx @@ -0,0 +1,101 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { render, screen, fireEvent } from '@testing-library/react' +import { MatchedClustersModal } from './MatchedClustersModal' + +jest.mock('../../lib/acm-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, vars?: Record) => { + if (vars) { + return Object.entries(vars).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) + } + return key + }, + }), +})) + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + matchedClusters: ['cluster1', 'cluster2'], + notMatchedClusters: ['cluster3'], + totalClusters: 3, +} + +describe('MatchedClustersModal', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders matched and not-matched sections when both are present', () => { + render() + expect(screen.getByText('Matched')).toBeInTheDocument() + expect(screen.getByText('Not matched')).toBeInTheDocument() + expect(screen.getByText('cluster1')).toBeInTheDocument() + expect(screen.getByText('cluster2')).toBeInTheDocument() + expect(screen.getByText('cluster3')).toBeInTheDocument() + }) + + it('renders flat list without section headers when no notMatchedClusters', () => { + render() + expect(screen.getByText('cluster1')).toBeInTheDocument() + expect(screen.getByText('cluster2')).toBeInTheDocument() + expect(screen.queryByText('Matched')).not.toBeInTheDocument() + expect(screen.queryByText('Not matched')).not.toBeInTheDocument() + }) + + it('filters clusters by search term', () => { + render() + const searchInput = screen.getByPlaceholderText('Find by name') + fireEvent.change(searchInput, { target: { value: 'cluster1' } }) + expect(screen.getByText('cluster1')).toBeInTheDocument() + expect(screen.queryByText('cluster2')).not.toBeInTheDocument() + expect(screen.queryByText('cluster3')).not.toBeInTheDocument() + }) + + it('shows empty state when search matches nothing', () => { + render() + const searchInput = screen.getByPlaceholderText('Find by name') + fireEvent.change(searchInput, { target: { value: 'nonexistent' } }) + expect(screen.getByText('No clusters found matching "nonexistent"')).toBeInTheDocument() + }) + + it('shows "No clusters" when empty and no search term', () => { + render() + expect(screen.getByText('No clusters')).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('cluster1')).not.toBeInTheDocument() + }) + + it('calls onClose when modal is dismissed', () => { + render() + const closeButton = screen.getByLabelText('Close') + fireEvent.click(closeButton) + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it('filters notMatched clusters independently', () => { + render() + const searchInput = screen.getByPlaceholderText('Find by name') + fireEvent.change(searchInput, { target: { value: 'cluster3' } }) + expect(screen.queryByText('cluster1')).not.toBeInTheDocument() + expect(screen.getByText('cluster3')).toBeInTheDocument() + expect(screen.queryByText('Matched')).not.toBeInTheDocument() + expect(screen.getByText('Not matched')).toBeInTheDocument() + }) + + it('clears search when clear button is clicked', () => { + render() + const searchInput = screen.getByPlaceholderText('Find by name') + fireEvent.change(searchInput, { target: { value: 'cluster1' } }) + expect(screen.queryByText('cluster2')).not.toBeInTheDocument() + + const clearButton = screen.getByLabelText('Reset') + fireEvent.click(clearButton) + expect(screen.getByText('cluster1')).toBeInTheDocument() + expect(screen.getByText('cluster2')).toBeInTheDocument() + expect(screen.getByText('cluster3')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/wizards/Placement/MatchedClustersModal.tsx b/frontend/src/wizards/Placement/MatchedClustersModal.tsx new file mode 100644 index 00000000000..fac65aeeda5 --- /dev/null +++ b/frontend/src/wizards/Placement/MatchedClustersModal.tsx @@ -0,0 +1,119 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { Modal, ModalBody, ModalHeader, ModalVariant, SearchInput, Tooltip } from '@patternfly/react-core' +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from '../../lib/acm-i18next' + +export interface MatchedClustersModalProps { + isOpen: boolean + onClose: () => void + matchedClusters: string[] + notMatchedClusters: string[] + totalClusters: number +} + +export function MatchedClustersModal(props: MatchedClustersModalProps) { + const { t } = useTranslation() + const [searchTerm, setSearchTerm] = useState('') + + useEffect(() => { + if (props.isOpen) setSearchTerm('') + }, [props.isOpen]) + + const filteredMatched = useMemo(() => { + if (!searchTerm) return props.matchedClusters + const lower = searchTerm.toLowerCase() + return props.matchedClusters.filter((name) => name.toLowerCase().includes(lower)) + }, [props.matchedClusters, searchTerm]) + + const filteredNotMatched = useMemo(() => { + if (!searchTerm) return props.notMatchedClusters + const lower = searchTerm.toLowerCase() + return props.notMatchedClusters.filter((name) => name.toLowerCase().includes(lower)) + }, [props.notMatchedClusters, searchTerm]) + + const hasLimit = props.notMatchedClusters.length > 0 + const matchedLen = props.matchedClusters.length + const title = hasLimit + ? props.totalClusters === 1 + ? t('{{matched}} of {{total}} cluster matched', { matched: matchedLen, total: props.totalClusters }) + : t('{{matched}} of {{total}} clusters matched', { matched: matchedLen, total: props.totalClusters }) + : matchedLen === 1 + ? t('{{matched}} cluster matched', { matched: matchedLen }) + : t('{{matched}} clusters matched', { matched: matchedLen }) + + const rowStyle = { + padding: '0.75rem 1rem', + backgroundColor: 'var(--pf-t--global--background--color--secondary--default)', + borderBottom: '1px solid var(--pf-t--global--border--color--default)', + } + + return ( + + + +
+

{t('Showing clusters that match your defined labels, tolerations, and limits.')}

+ + setSearchTerm(value)} + onClear={() => setSearchTerm('')} + /> + +
+ {hasLimit && filteredMatched.length > 0 && ( +
+

+ {t('Matched')}{' '} + + + +

+ {filteredMatched.map((name) => ( +
+ {name} +
+ ))} +
+ )} + + {hasLimit && filteredNotMatched.length > 0 && ( +
+

+ {t('Not matched')}{' '} + + + +

+ {filteredNotMatched.map((name) => ( +
+ {name} +
+ ))} +
+ )} + + {!hasLimit && + filteredMatched.map((name) => ( +
+ {name} +
+ ))} + + {filteredMatched.length === 0 && filteredNotMatched.length === 0 && ( +
+ {searchTerm ? t('No clusters found matching "{{search}}"', { search: searchTerm }) : t('No clusters')} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/wizards/Placement/Placement.test.tsx b/frontend/src/wizards/Placement/Placement.test.tsx new file mode 100644 index 00000000000..8a81fca813e --- /dev/null +++ b/frontend/src/wizards/Placement/Placement.test.tsx @@ -0,0 +1,356 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { render, screen } from '@testing-library/react' +import { Placement, Placements, PlacementPredicate, PredicateSummary } from './Placement' +import { PlacementDebugState } from './usePlacementDebug' + +let mockPlacement: any = { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: 'Placement', + metadata: { name: 'test-placement', namespace: 'default' }, + spec: {}, +} + +let mockResources: any[] = [] +const mockUpdate = jest.fn() +const mockSetFooterContent = jest.fn() +let mockEditMode = 'create' + +jest.mock('../../lib/acm-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts) { + return Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) + } + return key + }, + }), +})) + +jest.mock('../../hooks/useValidation', () => ({ + useValidation: () => ({ validateKubernetesResourceName: jest.fn() }), +})) + +jest.mock('../common/useLabelValuesMap', () => ({ + useLabelValuesMap: () => ({}), +})) + +jest.mock('./usePlacementDebug', () => ({ + usePlacementDebug: jest.fn(() => ({ + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: undefined, + })), +})) + +jest.mock('./MatchedClustersModal', () => ({ + MatchedClustersModal: () => null, +})) + +jest.mock('./MatchExpression', () => ({ + MatchExpression: () =>
, + MatchExpressionCollapsed: () =>
, + MatchExpressionSummary: ({ expression }: any) =>
{expression?.key}
, +})) + +jest.mock('@patternfly-labs/react-form-wizard', () => ({ + DisplayMode: { Step: 0, StepsHidden: 1 }, + EditMode: { Create: 'create', Edit: 'edit' }, + useData: () => ({ update: mockUpdate }), + useDisplayMode: () => 0, + useEditMode: () => mockEditMode, + useItem: () => mockPlacement ?? mockResources, + useSetFooterContent: () => mockSetFooterContent, + WizArrayInput: ({ children, label }: any) =>
{children}
, + WizCheckbox: ({ label }: any) =>
, + WizCustomWrapper: ({ label, value, children }: any) => ( +
+ {value} + {children} +
+ ), + WizKeyValue: ({ label }: any) =>
, + WizLabelSelect: ({ label }: any) =>
, + WizMultiSelect: ({ label }: any) =>
, + WizNumberInput: ({ label }: any) =>
, + WizTextInput: ({ label, id }: any) =>
, +})) + +describe('Placements', () => { + beforeEach(() => { + mockEditMode = 'create' + mockResources = [ + { + kind: 'Policy', + metadata: { name: 'test-policy', namespace: 'default' }, + }, + ] + }) + + it('renders the placements wrapper with array input', () => { + render() + + expect(screen.getByTestId('array-input-Placements')).toBeInTheDocument() + }) + + it('filters namespace cluster set names from bindings', () => { + mockResources = [ + { + kind: 'Policy', + metadata: { name: 'test-policy', namespace: 'default' }, + }, + ] + + render( + + ) + + expect(screen.getByTestId('array-input-Placements')).toBeInTheDocument() + }) + + it('returns empty cluster set names when no source found', () => { + mockResources = [{ kind: 'Other', metadata: { name: 'test', namespace: 'default' } }] + + render() + + expect(screen.getByTestId('array-input-Placements')).toBeInTheDocument() + }) + + it('renders in edit mode with default collapsed', () => { + mockEditMode = 'edit' + + render() + + expect(screen.getByTestId('array-input-Placements')).toBeInTheDocument() + }) +}) + +describe('Placement', () => { + beforeEach(() => { + mockPlacement = { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: 'Placement', + metadata: { name: 'test-placement', namespace: 'default' }, + spec: {}, + } + mockEditMode = 'create' + mockSetFooterContent.mockReset() + }) + + it('renders basic placement fields', () => { + render() + + expect(screen.getByTestId('text-input-name')).toBeInTheDocument() + expect(screen.getByTestId('multi-select-Cluster sets')).toBeInTheDocument() + expect(screen.getByTestId('checkbox-Set a limit on the number of clusters selected')).toBeInTheDocument() + }) + + it('hides name when hideName is true', () => { + render() + + expect(screen.queryByTestId('text-input-name')).not.toBeInTheDocument() + }) + + it('shows cluster set warning alert when no cluster sets and alertTitle provided', () => { + render( + Please create a cluster set} + /> + ) + + expect(screen.getByText('No cluster sets available')).toBeInTheDocument() + expect(screen.getByText('Please create a cluster set')).toBeInTheDocument() + }) + + it('does not show alert when cluster sets are present', () => { + render() + + expect(screen.queryByText('No cluster sets available')).not.toBeInTheDocument() + }) + + it('does not render feature flag UI when showPlacementPreview is false', () => { + render() + + expect(screen.queryByTestId('custom-wrapper-Matched by Placement')).not.toBeInTheDocument() + }) + + it('renders WizCustomWrapper when showPlacementPreview is true', () => { + render() + + expect(screen.getByTestId('custom-wrapper-Matched by Placement')).toBeInTheDocument() + expect(screen.getByTestId('custom-wrapper-value')).toHaveTextContent('-') + }) + + it('shows matched count when placementDebugState has matches', () => { + const debugState: PlacementDebugState = { + matched: ['c1', 'c2'], + notMatched: ['c3'], + totalClusters: 3, + matchedCount: 2, + loading: false, + error: undefined, + } + + render( + + ) + + expect(screen.getByTestId('custom-wrapper-value')).toHaveTextContent('Matched by Placement: 2 of 3 clusters') + }) + + it('shows no-match message when matchedCount is 0', () => { + const debugState: PlacementDebugState = { + matched: [], + notMatched: ['c1'], + totalClusters: 1, + matchedCount: 0, + loading: false, + error: undefined, + } + + render( + + ) + + expect(screen.getByTestId('custom-wrapper-value')).toHaveTextContent( + 'No clusters match the current placement criteria' + ) + }) + + it('shows error alert when placementDebugState has error', () => { + const debugState: PlacementDebugState = { + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: new Error('500 Internal Server Error'), + } + + render( + + ) + + expect(screen.getByText('Unable to determine cluster matches.')).toBeInTheDocument() + }) + + it('does not show alert when cluster has clusterSets in spec', () => { + mockPlacement.spec = { clusterSets: ['my-set'] } + + render() + + expect(screen.queryByText('No cluster sets available')).not.toBeInTheDocument() + }) + + it('sets footer content when owning debug UI', () => { + render() + + expect(mockSetFooterContent).toHaveBeenCalled() + }) + + it('renders numberOfClusters checkbox and number input', () => { + mockPlacement.spec = { numberOfClusters: 3 } + + render() + + expect(screen.getByTestId('checkbox-Set a limit on the number of clusters selected')).toBeInTheDocument() + expect(screen.getByTestId('number-input-Limit the number of clusters selected')).toBeInTheDocument() + }) + + it('renders with readonly name when placement has uid', () => { + mockPlacement.metadata.uid = 'abc-123' + + render() + + expect(screen.getByTestId('text-input-name')).toBeInTheDocument() + }) + + it('renders predicate section with custom rootPath', () => { + render() + + expect(screen.getByTestId('key-value-Label selectors')).toBeInTheDocument() + }) +}) + +describe('PlacementPredicate', () => { + it('renders label and claim expression inputs', () => { + render() + + expect(screen.getByTestId('key-value-Label selectors')).toBeInTheDocument() + expect(screen.getByText('Label expressions')).toBeInTheDocument() + expect(screen.getByTestId('array-input-Cluster claim expressions')).toBeInTheDocument() + }) +}) + +describe('PredicateSummary', () => { + it('shows expand message when no selectors or expressions', () => { + mockPlacement = { requiredClusterSelector: {} } + + render() + + expect(screen.getByText('Expand to enter expression')).toBeInTheDocument() + }) + + it('renders label selectors', () => { + mockPlacement = { + requiredClusterSelector: { + labelSelector: { + matchLabels: { env: 'prod', region: 'us-east' }, + matchExpressions: [], + }, + claimSelector: { matchExpressions: [] }, + }, + } + + render() + + expect(screen.getByText('env=prod')).toBeInTheDocument() + expect(screen.getByText('region=us-east')).toBeInTheDocument() + expect(screen.getByText('Label selectors')).toBeInTheDocument() + }) + + it('renders label expressions', () => { + mockPlacement = { + requiredClusterSelector: { + labelSelector: { + matchLabels: {}, + matchExpressions: [{ key: 'env', operator: 'In', values: ['prod'] }], + }, + claimSelector: { matchExpressions: [] }, + }, + } + + render() + + expect(screen.getByText('Label expressions')).toBeInTheDocument() + expect(screen.getByTestId('match-expression-summary')).toHaveTextContent('env') + }) + + it('renders claim expressions', () => { + mockPlacement = { + requiredClusterSelector: { + labelSelector: { matchLabels: {}, matchExpressions: [] }, + claimSelector: { + matchExpressions: [{ key: 'platform', operator: 'In', values: ['AWS'] }], + }, + }, + } + + render() + + expect(screen.getByText('Cluster claim expressions')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/wizards/Placement/Placement.tsx b/frontend/src/wizards/Placement/Placement.tsx index eaf6398243e..90d1785d3c8 100644 --- a/frontend/src/wizards/Placement/Placement.tsx +++ b/frontend/src/wizards/Placement/Placement.tsx @@ -1,21 +1,25 @@ /* Copyright Contributors to the Open Cluster Management project */ import { + DisplayMode, EditMode, useData, + useDisplayMode, useEditMode, useItem, + useSetFooterContent, WizArrayInput, WizCheckbox, + WizCustomWrapper, WizKeyValue, + WizLabelSelect, WizMultiSelect, WizNumberInput, WizTextInput, - WizLabelSelect, } from '@patternfly-labs/react-form-wizard' -import { Button, Divider, ExpandableSection, Label } from '@patternfly/react-core' +import { Alert, Button, ButtonVariant, Divider, ExpandableSection, Flex, Label, Tooltip } from '@patternfly/react-core' import { ExternalLinkAltIcon } from '@patternfly/react-icons' import get from 'get-value' -import { Fragment, ReactNode, useMemo, useState } from 'react' +import { Fragment, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import set from 'set-value' import { useTranslation } from '../../lib/acm-i18next' import { useValidation } from '../../hooks/useValidation' @@ -24,6 +28,9 @@ import { IPlacement, PlacementKind, PlacementType, Predicate, Toleration } from import { IResource } from '../common/resources/IResource' import { useLabelValuesMap } from '../common/useLabelValuesMap' import { MatchExpression, MatchExpressionCollapsed, MatchExpressionSummary } from './MatchExpression' +import './MatchExpression.css' +import { MatchedClustersModal } from './MatchedClustersModal' +import { PlacementDebugState, usePlacementDebug } from './usePlacementDebug' function TolerationCollapsed() { const toleration = useItem() as Toleration @@ -40,6 +47,7 @@ export function Placements(props: { clusters: IResource[] createClusterSetCallback?: () => void alertTitle?: string + showPlacementPreview?: boolean }) { const editMode = useEditMode() const resources = useItem() as IResource[] @@ -82,6 +90,7 @@ export function Placements(props: { clusters={props.clusters} createClusterSetCallback={props.createClusterSetCallback} alertTitle={props.alertTitle} + showPlacementPreview={props.showPlacementPreview} /> ) @@ -94,17 +103,94 @@ export function Placement(props: { createClusterSetCallback?: () => void alertTitle?: string alertContent?: ReactNode + showPlacementPreview?: boolean + placementDebugState?: PlacementDebugState }) { const placement = useItem() as IPlacement + const isClusterSet = placement.spec?.clusterSets?.length const editMode = useEditMode() + const displayMode = useDisplayMode() const { update } = useData() + const [isMatchedClustersModalOpen, setIsMatchedClustersModalOpen] = useState(false) const [isTolerationsExpanded, setIsTolerationsExpanded] = useState(true) + const featureEnabled = props.showPlacementPreview === true + const ownsDebugUI = featureEnabled && !props.placementDebugState + const ownDebugState = usePlacementDebug(placement, ownsDebugUI) + const { matched, notMatched, totalClusters, matchedCount, error } = props.placementDebugState ?? ownDebugState const { t } = useTranslation() const { validateKubernetesResourceName } = useValidation() + const matchedLabel = + matchedCount === undefined + ? '-' + : t('{{matched}} of {{total}} clusters', { matched: matchedCount, total: totalClusters }) + + const setFooterContent = useSetFooterContent() + const openMatchedModal = useCallback(() => setIsMatchedClustersModalOpen(true), []) + + useEffect(() => { + if (!ownsDebugUI) return + if (displayMode === DisplayMode.Step) { + setFooterContent( +
+ {t('Matched by Placement')}:{' '} + {error ? ( + + + + ) : ( + + )} +
+ ) + } else { + setFooterContent(undefined) + } + return () => setFooterContent(undefined) + }, [ownsDebugUI, displayMode, matchedLabel, error, setFooterContent, openMatchedModal, t]) + return ( + {featureEnabled && ( + 0 ? ( + + {t('Matched by Placement')}:{' '} + + + ) : ( + t( + 'No clusters match the current placement criteria. To identify available clusters, check your label expressions, tolerations, or limits.' + ) + ) + } + nonEditable + alertVariant={ + error ? 'warning' : matchedCount === undefined ? undefined : matchedCount > 0 ? 'info' : 'warning' + } + > + + + )} {!props.hideName && ( )} + {!isClusterSet && !props.namespaceClusterSetNames.length && props.alertTitle ? ( + + {props.alertContent} + + ) : null} + - - - ) } diff --git a/frontend/src/wizards/Placement/PlacementSection.test.tsx b/frontend/src/wizards/Placement/PlacementSection.test.tsx new file mode 100644 index 00000000000..8ff69554539 --- /dev/null +++ b/frontend/src/wizards/Placement/PlacementSection.test.tsx @@ -0,0 +1,569 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { render, screen } from '@testing-library/react' +import { PlacementSection, PlacementSelector } from './PlacementSection' +import { PlacementKind } from '../common/resources/IPlacement' +import { PlacementBindingKind } from '../common/resources/IPlacementBinding' + +let mockResources: any[] = [] +const mockUpdate = jest.fn() +const mockSetFooterContent = jest.fn() +const mockSetHasInputs = jest.fn() +const mockValidate = jest.fn() +let mockSettings: Record = {} +let mockEditMode = 'create' +let mockDisplayMode = 0 + +jest.mock('../../lib/acm-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts) { + return Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) + } + return key + }, + }), +})) + +jest.mock('../../shared-recoil', () => ({ + useRecoilValue: () => mockSettings, + useSharedAtoms: () => ({ settingsState: 'settingsState' }), +})) + +jest.mock('./usePlacementDebug', () => ({ + usePlacementDebug: jest.fn(() => ({ + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: undefined, + })), +})) + +jest.mock('./MatchedClustersModal', () => ({ + MatchedClustersModal: () => null, +})) + +jest.mock('./Placement', () => ({ + Placement: (props: any) =>
, + Placements: (props: any) =>
, +})) + +jest.mock('./PlacementBinding', () => ({ + PlacementBindings: () =>
, +})) + +jest.mock('../../NavigationPath', () => ({ + NavigationPath: { clusterSets: '/cluster-sets' }, +})) + +jest.mock('@patternfly-labs/react-form-wizard', () => ({ + DisplayMode: { Step: 0, StepsHidden: 1 }, + EditMode: { Create: 'create', Edit: 'edit' }, + useData: () => ({ update: mockUpdate }), + useDisplayMode: () => mockDisplayMode, + useEditMode: () => mockEditMode, + useItem: () => mockResources, + useSetFooterContent: () => mockSetFooterContent, + useSetHasInputs: () => mockSetHasInputs, + useValidate: () => mockValidate, + Section: ({ children, label }: any) =>
{children}
, + Sync: () => null, + WizDetailsHidden: ({ children }: any) => <>{children}, + WizItemSelector: ({ children }: any) =>
{children}
, + WizSingleSelect: ({ label }: any) =>
, +})) + +const defaultProps = { + bindingSubjectKind: 'Policy', + bindingSubjectApiGroup: 'policy.open-cluster-management.io', + existingPlacements: [], + existingClusterSets: [], + existingClusterSetBindings: [], + clusters: [], +} + +describe('PlacementSection', () => { + beforeEach(() => { + mockResources = [] + mockSettings = {} + mockEditMode = 'create' + mockDisplayMode = 0 + mockUpdate.mockReset() + mockSetFooterContent.mockReset() + }) + + it('renders the placement section with empty resources', () => { + render() + + expect(screen.getByTestId('section-Placement')).toBeInTheDocument() + }) + + it('renders Placement component when one placement exists', () => { + mockResources = [ + { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + expect(screen.getByTestId('placement-component')).toBeInTheDocument() + }) + + it('renders advanced view when multiple placements exist', () => { + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'p1', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementKind, + metadata: { name: 'p2', namespace: 'default' }, + spec: {}, + }, + ] + + render() + + expect(screen.getByTestId('placements-component')).toBeInTheDocument() + expect(screen.getByTestId('placement-bindings')).toBeInTheDocument() + }) + + it('passes showPlacementPreview when enhancedPlacement is enabled', () => { + mockSettings = { enhancedPlacement: 'enabled' } + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + const placement = screen.getByTestId('placement-component') + expect(placement).toHaveAttribute('data-feature', 'true') + }) + + it('renders existing placement selector when only binding exists', () => { + mockResources = [ + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + expect(screen.getByTestId('single-select-Placement')).toBeInTheDocument() + }) + + it('auto-creates placement binding when single placement exists without binding', () => { + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + ] + + render() + + expect(mockUpdate).toHaveBeenCalled() + const binding = mockResources.find((r) => r.kind === PlacementBindingKind) + expect(binding).toBeDefined() + expect(binding.placementRef.kind).toBe(PlacementKind) + }) + + it('hides placement selector in edit mode with existing resources', () => { + mockEditMode = 'edit' + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default', uid: 'existing-uid' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + expect(screen.queryByText('How do you want to select clusters?')).not.toBeInTheDocument() + }) + + it('shows placement selector in edit mode without existing resources', () => { + mockEditMode = 'edit' + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + expect(screen.getByText('How do you want to select clusters?')).toBeInTheDocument() + }) + + it('detects advanced mode with multiple placement bindings', () => { + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'p1', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'b1', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'p1' }, + subjects: [], + }, + { + kind: PlacementBindingKind, + metadata: { name: 'b2', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'p1' }, + subjects: [], + }, + ] + + render() + + expect(screen.getByTestId('placements-component')).toBeInTheDocument() + expect(screen.getByTestId('placement-bindings')).toBeInTheDocument() + }) + + it('detects advanced mode with multiple predicates', () => { + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'p1', namespace: 'default' }, + spec: { + predicates: [{ requiredClusterSelector: {} }, { requiredClusterSelector: {} }], + }, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'b1', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'p1' }, + subjects: [], + }, + ] + + render() + + expect(screen.getByTestId('placements-component')).toBeInTheDocument() + }) + + it('filters namespaced placements by namespace', () => { + mockResources = [ + { + kind: 'Policy', + metadata: { name: 'test-policy', namespace: 'ns1' }, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'ns1' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'existing' }, + subjects: [], + }, + ] + + const existingPlacements = [ + { metadata: { name: 'p-ns1', namespace: 'ns1' } }, + { metadata: { name: 'p-ns2', namespace: 'ns2' } }, + ] + + render() + + expect(screen.getByTestId('section-Placement')).toBeInTheDocument() + }) + + it('filters cluster set bindings by namespace', () => { + mockResources = [ + { + kind: 'Policy', + metadata: { name: 'test-policy', namespace: 'ns1' }, + }, + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'ns1' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'ns1' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render( + + ) + + expect(screen.getByTestId('placement-component')).toBeInTheDocument() + }) + + it('sets footer content when enhancedPlacement is enabled with single placement', () => { + mockSettings = { enhancedPlacement: 'enabled' } + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + expect(mockSetFooterContent).toHaveBeenCalled() + }) + + it('renders review step content when displayMode is not Step and enhancedPlacement enabled', () => { + mockSettings = { enhancedPlacement: 'enabled' } + mockDisplayMode = 1 + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + render() + + expect(screen.getByTestId('section-Placement')).toBeInTheDocument() + }) + + it('renders review step with label expressions and tolerations', () => { + mockSettings = { enhancedPlacement: 'enabled' } + mockDisplayMode = 1 + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: { + predicates: [ + { + requiredClusterSelector: { + labelSelector: { + matchExpressions: [{ key: 'env', operator: 'In', values: ['prod'] }], + }, + }, + }, + ], + tolerations: [{ key: 'dedicated', operator: 'Equal', value: 'gpu', effect: 'NoSchedule' }], + }, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + const { usePlacementDebug } = jest.requireMock('./usePlacementDebug') + usePlacementDebug.mockReturnValue({ + matched: ['c1'], + notMatched: [], + totalClusters: 1, + matchedCount: 1, + loading: false, + error: undefined, + }) + + render() + + expect(screen.getByText('Label expressions and tolerations')).toBeInTheDocument() + expect(screen.getByText('env In')).toBeInTheDocument() + expect(screen.getByText('dedicated')).toBeInTheDocument() + expect(screen.getByText('gpu')).toBeInTheDocument() + expect(screen.getByText('NoSchedule')).toBeInTheDocument() + + usePlacementDebug.mockReturnValue({ + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: undefined, + }) + }) + + it('renders review step error alert when debug has error', () => { + mockSettings = { enhancedPlacement: 'enabled' } + mockDisplayMode = 1 + mockResources = [ + { + kind: PlacementKind, + metadata: { name: 'test', namespace: 'default' }, + spec: {}, + }, + { + kind: PlacementBindingKind, + metadata: { name: 'test-binding', namespace: 'default' }, + placementRef: { apiGroup: 'cluster.open-cluster-management.io', kind: PlacementKind, name: 'test' }, + subjects: [], + }, + ] + + const { usePlacementDebug } = jest.requireMock('./usePlacementDebug') + usePlacementDebug.mockReturnValue({ + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: new Error('Server error'), + }) + + render() + + expect(screen.getByText('Unable to determine cluster matches.')).toBeInTheDocument() + + usePlacementDebug.mockReturnValue({ + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: undefined, + }) + }) +}) + +describe('PlacementSelector', () => { + beforeEach(() => { + mockResources = [ + { + kind: 'Policy', + metadata: { name: 'test-policy', namespace: 'default' }, + }, + ] + mockUpdate.mockReset() + }) + + it('renders toggle group with new and existing placement options', () => { + render( + + ) + + expect(screen.getByText('New placement')).toBeInTheDocument() + expect(screen.getByText('Existing placement')).toBeInTheDocument() + }) + + it('renders no placement option when allowNoPlacement is true', () => { + render( + + ) + + expect(screen.getByText('No placement')).toBeInTheDocument() + }) + + it('shows helper text when no placement is selected and allowNoPlacement', () => { + render( + + ) + + expect( + screen.getByText('Do not add a placement if you want to place this policy using policy set placement.') + ).toBeInTheDocument() + }) + + it('highlights new placement toggle when placement exists', () => { + render( + + ) + + expect(screen.getByText('New placement')).toBeInTheDocument() + }) + + it('highlights existing placement toggle when only binding exists', () => { + render( + + ) + + expect(screen.getByText('Existing placement')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/wizards/Placement/PlacementSection.tsx b/frontend/src/wizards/Placement/PlacementSection.tsx index 9567e4bae37..992517277af 100644 --- a/frontend/src/wizards/Placement/PlacementSection.tsx +++ b/frontend/src/wizards/Placement/PlacementSection.tsx @@ -1,6 +1,15 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { Button, ToggleGroup, ToggleGroupItem } from '@patternfly/react-core' -import { Fragment, useEffect, useMemo, useState } from 'react' +import { + Alert, + Button, + ButtonVariant, + Label, + LabelGroup, + ToggleGroup, + ToggleGroupItem, + Tooltip, +} from '@patternfly/react-core' +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { WizDetailsHidden, EditMode, @@ -13,6 +22,9 @@ import { useItem, useValidate, Sync, + useSetFooterContent, + DisplayMode, + useDisplayMode, } from '@patternfly-labs/react-form-wizard' import { IResource } from '../common/resources/IResource' @@ -30,6 +42,9 @@ import { Placement, Placements } from './Placement' import { PlacementBindings } from './PlacementBinding' import { useTranslation } from '../../lib/acm-i18next' import { NavigationPath } from '../../NavigationPath' +import { usePlacementDebug } from './usePlacementDebug' +import { MatchedClustersModal } from './MatchedClustersModal' +import { useRecoilValue, useSharedAtoms } from '../../shared-recoil' export function PlacementSection(props: { bindingSubjectKind: string @@ -47,6 +62,10 @@ export function PlacementSection(props: { const { update } = useData() const resources = useItem() as IResource[] const editMode = useEditMode() + const displayMode = useDisplayMode() + const { settingsState } = useSharedAtoms() + const settings = useRecoilValue(settingsState) + const [isMatchedClustersModalOpen, setIsMatchedClustersModalOpen] = useState(false) const [placementCount, setPlacementCount] = useState(0) const [placementBindingCount, setPlacementBindingCount] = useState(0) @@ -141,6 +160,60 @@ export function PlacementSection(props: { setHasInputs() }, [setHasInputs]) + // Calculate matched clusters for the current placement + const currentPlacement = useMemo(() => { + return resources?.find((resource) => resource.kind === PlacementKind) as IPlacement | undefined + }, [resources]) + + const debugState = usePlacementDebug(currentPlacement, settings.enhancedPlacement === 'enabled') + const { matched, notMatched, matchedCount, totalClusters, error } = debugState + + const setFooterContent = useSetFooterContent() + const openMatchedModal = useCallback(() => setIsMatchedClustersModalOpen(true), []) + + useEffect(() => { + if ( + settings.enhancedPlacement === 'enabled' && + placementCount === 1 && + currentPlacement && + displayMode === DisplayMode.Step + ) { + const matchedLabel = + matchedCount === undefined + ? '-' + : t('{{matched}} of {{total}} clusters', { matched: matchedCount, total: totalClusters }) + + setFooterContent( +
+ {t('Matched by Placement')}:{' '} + {error ? ( + + + + ) : ( + + )} +
+ ) + } else { + setFooterContent(undefined) + } + return () => setFooterContent(undefined) + }, [ + settings.enhancedPlacement, + placementCount, + currentPlacement, + displayMode, + matchedCount, + totalClusters, + error, + setFooterContent, + openMatchedModal, + t, + ]) + if (isAdvanced) { return ( @@ -150,6 +223,7 @@ export function PlacementSection(props: { clusterSetBindings={props.existingClusterSetBindings} bindingKind={props.bindingSubjectKind} clusters={props.clusters} + showPlacementPreview={settings.enhancedPlacement === 'enabled'} /> ) : null} } + showPlacementPreview={settings.enhancedPlacement === 'enabled'} + placementDebugState={debugState} /> @@ -226,6 +302,94 @@ export function PlacementSection(props: { /> )} + + {/* Review step content */} + {settings.enhancedPlacement === 'enabled' && + displayMode !== DisplayMode.Step && + placementCount === 1 && + currentPlacement && ( +
+ {/* Placement info alert */} + {error ? ( + + + + ) : ( + matchedCount !== undefined && ( + 0 ? 'info' : 'warning'} + isInline + title={ + matchedCount > 0 + ? t('{{matched}} of {{total}} clusters matched by placement', { + matched: matchedCount, + total: totalClusters, + }) + : t( + 'No clusters match the current placement criteria. To identify available clusters, check your label expressions, tolerations, or limits.' + ) + } + /> + ) + )} + + {/* Label expressions and tolerations */} + {(currentPlacement.spec?.predicates?.[0]?.requiredClusterSelector?.labelSelector?.matchExpressions + ?.length || + currentPlacement.spec?.tolerations?.length) && ( +
+

{t('Label expressions and tolerations')}

+
+ {/* Label expressions */} + {currentPlacement.spec?.predicates?.[0]?.requiredClusterSelector?.labelSelector?.matchExpressions + ?.length && ( +
+ {t('Label expressions')}: + + {currentPlacement.spec.predicates[0].requiredClusterSelector.labelSelector.matchExpressions.map( + (expr, idx) => ( + + + {expr.values && expr.values.length > 0 && } + + ) + )} + +
+ )} + + {/* Tolerations */} + {currentPlacement.spec?.tolerations?.length && ( +
+ {t('Tolerations')}: + + {currentPlacement.spec.tolerations.map((toleration, idx) => ( + + + + {toleration.value && } + {toleration.effect && } + {toleration.tolerationSeconds != null && ( + + )} + + ))} + +
+ )} +
+
+ )} +
+ )} + + setIsMatchedClustersModalOpen(false)} + matchedClusters={matched} + notMatchedClusters={notMatched} + totalClusters={totalClusters} + /> ) } diff --git a/frontend/src/wizards/Placement/usePlacementDebug.test.ts b/frontend/src/wizards/Placement/usePlacementDebug.test.ts new file mode 100644 index 00000000000..6c4fc833654 --- /dev/null +++ b/frontend/src/wizards/Placement/usePlacementDebug.test.ts @@ -0,0 +1,315 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { renderHook, act } from '@testing-library/react-hooks' +import { usePlacementDebug, clearPlacementDebugCache } from './usePlacementDebug' +import { IPlacement } from '../common/resources/IPlacement' +import { postPlacementDebug } from '../../resources/placement-debug' +import { ResourceError, ResourceErrorCode } from '../../resources/utils/resource-request' + +jest.mock('../../resources/placement-debug', () => ({ + postPlacementDebug: jest.fn(), +})) + +jest.mock('../../resources/utils/resource-request', () => { + const actual = jest.requireActual('../../resources/utils/resource-request') + return { + isRequestAbortedError: jest.fn((err) => err?.name === 'AbortError'), + ResourceError: actual.ResourceError, + ResourceErrorCode: actual.ResourceErrorCode, + } +}) + +const mockPostPlacementDebug = postPlacementDebug as jest.Mock + +const mockPlacement: IPlacement = { + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + kind: 'Placement', + metadata: { name: 'test', namespace: 'default' }, + spec: {}, +} + +const mockResult = { + aggregatedScores: [ + { clusterName: 'cluster1', score: 100 }, + { clusterName: 'cluster2', score: 80 }, + ], + filteredPipelineResults: [{ name: 'filter1', filteredClusters: ['cluster3'] }], +} + +describe('usePlacementDebug', () => { + beforeEach(() => { + jest.useFakeTimers() + mockPostPlacementDebug.mockReset() + clearPlacementDebugCache() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('returns empty state when disabled', () => { + const { result } = renderHook(() => usePlacementDebug(mockPlacement, false)) + + expect(result.current.matchedCount).toBeUndefined() + expect(result.current.loading).toBe(false) + expect(result.current.matched).toEqual([]) + expect(result.current.notMatched).toEqual([]) + expect(result.current.error).toBeUndefined() + }) + + it('returns empty state when placement is undefined', () => { + const { result } = renderHook(() => usePlacementDebug(undefined)) + + expect(result.current.matchedCount).toBeUndefined() + expect(result.current.loading).toBe(false) + expect(result.current.matched).toEqual([]) + expect(result.current.notMatched).toEqual([]) + expect(result.current.error).toBeUndefined() + }) + + it('fetches and maps successful result', async () => { + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.resolve(mockResult), + abort: jest.fn(), + }) + + const { result } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + // After render but before debounce fires, should be loading + expect(result.current.loading).toBe(true) + + // Advance past the 500ms debounce + act(() => { + jest.advanceTimersByTime(500) + }) + + // Wait for the promise to resolve + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.matched).toEqual(['cluster1', 'cluster2']) + expect(result.current.notMatched).toEqual(['cluster3']) + expect(result.current.matchedCount).toBe(2) + expect(result.current.totalClusters).toBe(3) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeUndefined() + }) + + it('maps server-returned error from result', async () => { + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.resolve({ error: 'placement namespace not found' }), + abort: jest.fn(), + }) + + const { result } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.error).toEqual(new Error('placement namespace not found')) + expect(result.current.matched).toEqual([]) + expect(result.current.matchedCount).toBeUndefined() + }) + + it('limits matched clusters by numberOfClusters and puts rest in notMatched', async () => { + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.resolve({ + placement: { spec: { numberOfClusters: 1 } }, + aggregatedScores: [ + { clusterName: 'cluster1', score: 100 }, + { clusterName: 'cluster2', score: 80 }, + { clusterName: 'cluster3', score: 60 }, + ], + filteredPipelineResults: [], + }), + abort: jest.fn(), + }) + + const { result } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.matched).toEqual(['cluster1']) + expect(result.current.notMatched).toEqual(['cluster2', 'cluster3']) + expect(result.current.matchedCount).toBe(1) + expect(result.current.totalClusters).toBe(3) + }) + + it('handles error state', async () => { + const testError = new Error('Network failure') + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.reject(testError), + abort: jest.fn(), + }) + + const { result } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.error).toEqual(testError) + expect(result.current.matched).toEqual([]) + expect(result.current.notMatched).toEqual([]) + expect(result.current.matchedCount).toBeUndefined() + expect(result.current.loading).toBe(false) + }) + + it('formats ResourceError with status code and reason', async () => { + const resourceError = new ResourceError( + ResourceErrorCode.InternalServerError, + 'Internal Server Error', + 'upstream service unavailable' + ) + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.reject(resourceError), + abort: jest.fn(), + }) + + const { result } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.error?.message).toBe('500 Internal Server Error: upstream service unavailable') + expect(result.current.matched).toEqual([]) + expect(result.current.matchedCount).toBeUndefined() + }) + + it('returns cached state on initial render when cache matches', async () => { + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.resolve(mockResult), + abort: jest.fn(), + }) + + const { result: first, unmount } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(first.current.matched).toEqual(['cluster1', 'cluster2']) + unmount() + + const { result: second } = renderHook(() => usePlacementDebug(mockPlacement, true)) + + expect(second.current.matched).toEqual(['cluster1', 'cluster2']) + expect(second.current.matchedCount).toBe(2) + expect(second.current.loading).toBe(false) + }) + + it('uses cached state when placement spec has not changed', async () => { + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.resolve(mockResult), + abort: jest.fn(), + }) + + const { result, rerender } = renderHook(({ placement }) => usePlacementDebug(placement, true), { + initialProps: { placement: mockPlacement }, + }) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.matched).toEqual(['cluster1', 'cluster2']) + + const callCount = mockPostPlacementDebug.mock.calls.length + + rerender({ placement: { ...mockPlacement } }) + + expect(result.current.matched).toEqual(['cluster1', 'cluster2']) + expect(result.current.loading).toBe(false) + expect(mockPostPlacementDebug).toHaveBeenCalledTimes(callCount) + }) + + it('clears stale state on re-fetch', async () => { + // First fetch resolves successfully + mockPostPlacementDebug.mockReturnValue({ + promise: Promise.resolve(mockResult), + abort: jest.fn(), + }) + + const { result, rerender } = renderHook(({ placement }) => usePlacementDebug(placement, true), { + initialProps: { placement: mockPlacement }, + }) + + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.matched).toEqual(['cluster1', 'cluster2']) + + // Prepare a new pending promise for the second fetch + let resolveSecond: (value: typeof mockResult) => void + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve + }) + mockPostPlacementDebug.mockReturnValue({ + promise: secondPromise, + abort: jest.fn(), + }) + + // Trigger re-fetch by changing the placement spec + const updatedPlacement: IPlacement = { + ...mockPlacement, + spec: { clusterSets: ['new-set'] }, + } + + rerender({ placement: updatedPlacement }) + + // After rerender with new spec, state should be cleared and loading + expect(result.current.loading).toBe(true) + expect(result.current.matched).toEqual([]) + expect(result.current.notMatched).toEqual([]) + + // Resolve the second fetch + act(() => { + jest.advanceTimersByTime(500) + }) + + await act(async () => { + resolveSecond!(mockResult) + await Promise.resolve() + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.matched).toEqual(['cluster1', 'cluster2']) + expect(result.current.loading).toBe(false) + }) +}) diff --git a/frontend/src/wizards/Placement/usePlacementDebug.ts b/frontend/src/wizards/Placement/usePlacementDebug.ts new file mode 100644 index 00000000000..ffa563d14a9 --- /dev/null +++ b/frontend/src/wizards/Placement/usePlacementDebug.ts @@ -0,0 +1,137 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { useEffect, useRef, useState } from 'react' +import debounce from 'debounce' +import { IPlacement } from '../common/resources/IPlacement' +import { postPlacementDebug, PlacementDebugResult } from '../../resources/placement-debug' +import { isRequestAbortedError, ResourceError } from '../../resources/utils/resource-request' + +export interface PlacementDebugState { + matched: string[] + notMatched: string[] + totalClusters: number + matchedCount: number | undefined + loading: boolean + error: Error | undefined +} + +const EMPTY_STATE: PlacementDebugState = { + matched: [], + notMatched: [], + totalClusters: 0, + matchedCount: undefined, + loading: false, + error: undefined, +} + +let cachedSpecKey: string | undefined +let cachedState: PlacementDebugState | undefined + +export function clearPlacementDebugCache() { + cachedSpecKey = undefined + cachedState = undefined +} + +function mapDebugResult(result: PlacementDebugResult): PlacementDebugState { + if (result.error) { + return { ...EMPTY_STATE, error: new Error(result.error) } + } + + const allMatched = (result.aggregatedScores ?? []).map((s) => s.clusterName) + const limit = result.placement?.spec?.numberOfClusters + const matched = limit !== undefined && limit >= 0 ? allMatched.slice(0, limit) : allMatched + const matchedSet = new Set(matched) + + const notMatched: string[] = [] + for (const clusterName of allMatched) { + if (!matchedSet.has(clusterName)) { + notMatched.push(clusterName) + } + } + for (const pipeline of result.filteredPipelineResults ?? []) { + for (const clusterName of pipeline.filteredClusters) { + if (!matchedSet.has(clusterName) && !notMatched.includes(clusterName)) { + notMatched.push(clusterName) + } + } + } + + return { + matched, + notMatched, + totalClusters: matched.length + notMatched.length, + matchedCount: matched.length, + loading: false, + error: undefined, + } +} + +export function usePlacementDebug(placement: IPlacement | undefined, enabled = true): PlacementDebugState { + const specKey = placement ? JSON.stringify({ metadata: placement.metadata, spec: placement.spec }) : undefined + + const [state, setState] = useState(() => { + if (enabled && specKey && specKey === cachedSpecKey && cachedState) { + return cachedState + } + return EMPTY_STATE + }) + const abortRef = useRef<(() => void) | undefined>(undefined) + + const debouncedFetchRef = useRef( + debounce((p: IPlacement, fetchKey: string) => { + abortRef.current?.() + setState((prev) => ({ ...prev, loading: true, error: undefined })) + + const { promise, abort } = postPlacementDebug(p) + abortRef.current = abort + + promise + .then((result) => { + const mapped = mapDebugResult(result) + cachedSpecKey = fetchKey + cachedState = mapped + setState(mapped) + }) + .catch((err: unknown) => { + if (isRequestAbortedError(err)) return + let error: Error + if (err instanceof ResourceError) { + const parts = [`${err.code} ${err.message}`] + if (err.reason) parts.push(err.reason) + error = new Error(parts.join(': ')) + } else { + error = err instanceof Error ? err : new Error(String(err)) + } + const errorState = { ...EMPTY_STATE, error } + cachedSpecKey = fetchKey + cachedState = errorState + setState(errorState) + }) + }, 500) + ) + + useEffect(() => { + const debouncedFetch = debouncedFetchRef.current + if (!enabled || !specKey || !placement) { + setState(EMPTY_STATE) + return + } + + if (specKey === cachedSpecKey && cachedState) { + setState(cachedState) + return + } + + setState({ ...EMPTY_STATE, loading: true }) + debouncedFetch(placement, specKey) + + return () => { + debouncedFetch.clear() + abortRef.current?.() + } + // placement is intentionally omitted — specKey is derived from it and + // serves as the sole cache/identity key. Including placement would cause + // spurious re-fetches on every render due to referential inequality. + }, [specKey, enabled]) // eslint-disable-line react-hooks/exhaustive-deps + + return state +} diff --git a/setup.sh b/setup.sh index a69035a58d6..4a8aa6aef03 100755 --- a/setup.sh +++ b/setup.sh @@ -132,6 +132,24 @@ EOF echo SEARCH_API_URL=$SEARCH_API_URL >> ./backend/.env fi +# Create route to the placement debug service for local development. +oc apply -f - << EOF +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: cluster-manager-placement + namespace: open-cluster-management-hub +spec: + to: + kind: Service + name: cluster-manager-placement + tls: + termination: reencrypt + insecureEdgeTerminationPolicy: Redirect +EOF +PLACEMENT_DEBUG_URL=https://$(oc get route cluster-manager-placement -n open-cluster-management-hub -o="jsonpath={.status.ingress[0].host}")/debug/placements/ +echo PLACEMENT_DEBUG_URL=$PLACEMENT_DEBUG_URL >> ./backend/.env + CLUSTER_PROXY_ADDON_USER_HOST=$(oc get route cluster-proxy-addon-user -n $INSTALLATION_NAMESPACE_MCE -o="jsonpath={.status.ingress[0].host}") echo CLUSTER_PROXY_ADDON_USER_HOST=$CLUSTER_PROXY_ADDON_USER_HOST >> ./backend/.env CLUSTER_PROXY_ADDON_USER_ROUTE=https://$CLUSTER_PROXY_ADDON_USER_HOST