diff --git a/webpack/components/HostDetails/OpenscapCard/OpenscapCard.scss b/webpack/components/HostDetails/OpenscapCard/OpenscapCard.scss new file mode 100644 index 00000000..9c6c7851 --- /dev/null +++ b/webpack/components/HostDetails/OpenscapCard/OpenscapCard.scss @@ -0,0 +1,4 @@ +.openscap-rule-results { + flex-wrap: nowrap; + white-space: nowrap; +} diff --git a/webpack/components/HostDetails/OpenscapCard/OpenscapCard.test.js b/webpack/components/HostDetails/OpenscapCard/OpenscapCard.test.js new file mode 100644 index 00000000..90a6adfa --- /dev/null +++ b/webpack/components/HostDetails/OpenscapCard/OpenscapCard.test.js @@ -0,0 +1,252 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import OpenscapCard, { formatHostgroupTitle } from './index'; +import { sourceForPolicy } from './helpers'; +import { API } from 'foremanReact/redux/API'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; + +jest.mock('foremanReact/redux/API', () => ({ + API: { + put: jest.fn(() => Promise.resolve({})), + }, +})); + +jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({ + useAPI: jest.fn(), +})); + +jest.mock('foremanReact/common/helpers', () => ({ + foremanUrl: path => path, +})); + +jest.mock('foremanReact/common/I18n', () => ({ + translate: string => string, + sprintf: (string, ...args) => + args.reduce((formatted, arg) => formatted.replace('%s', arg), string), +})); + +jest.mock( + 'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate', + () => { + const React = require('react'); + return ({ header, children }) => + React.createElement( + 'section', + null, + React.createElement('h2', null, header), + children + ); + } +); + +jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => { + const React = require('react'); + return ({ date, defaultValue }) => + React.createElement('span', null, date || defaultValue); +}); + +const hostDetails = { + id: 42, + name: 'host.example.com', + compliance_status: 2, + compliance_status_label: 'Incompliant', + hostgroup_id: 7, + hostgroup_title: 'Parent/Child', +}; + +const latestReport = { + id: 99, + passed: 10, + failed: 2, + othered: 1, + ['reported_at']: '2026-07-07T10:00:00Z', +}; + +const assignedPolicies = [ + { + id: 1, + name: 'Direct policy', + hosts: [{ id: 42, name: 'host.example.com' }], + hostgroups: [], + }, + { + id: 2, + name: 'Host group policy', + hosts: [], + hostgroups: [{ id: 7, name: 'Child', title: 'Parent/Child' }], + }, + { + id: 3, + name: 'Parent host group policy', + hosts: [], + hostgroups: [{ id: 6, name: 'Parent', title: 'Parent' }], + }, +]; + +const unassignedPolicy = { + id: 4, + name: 'Available policy', + hosts: [{ id: 100, name: 'other.example.com' }], + hostgroups: [], +}; + +const apiFixtures = { + reports: { results: [latestReport] }, + policiesEnc: assignedPolicies.map(({ id }) => ({ id })), + policies: { results: [...assignedPolicies, unassignedPolicy] }, +}; + +const mockUseAPI = fixtures => { + useAPI.mockImplementation((_method, url) => { + if (url?.startsWith('/api/v2/compliance/arf_reports')) { + return { response: fixtures.reports, status: 'RESOLVED' }; + } + + if (url === '/api/v2/hosts/42/policies_enc') { + return { response: fixtures.policiesEnc, status: 'RESOLVED' }; + } + + if (url === '/api/v2/compliance/policies') { + return { response: fixtures.policies, status: 'RESOLVED' }; + } + + return { response: {}, status: 'RESOLVED' }; + }); +}; + +const renderCard = () => render(); + +describe('formatHostgroupTitle', () => { + it('formats hostgroup hierarchy like Foreman hostgroup selectors', () => { + expect(formatHostgroupTitle('Parent/Child/Leaf')).toBe( + 'Parent > Child > Leaf' + ); + }); +}); + +describe('sourceForPolicy', () => { + it('uses the most specific matching parent host group without sorting', () => { + expect( + sourceForPolicy( + { + hosts: [], + hostgroups: [ + { id: 5, name: 'Parent', title: 'Parent' }, + { id: 6, name: 'Child', title: 'Parent/Child' }, + ], + }, + { + id: 42, + hostgroup_id: 7, + hostgroup_title: 'Parent/Child/Leaf', + } + ).label + ).toBe('Parent host group: Parent > Child'); + }); +}); + +describe('OpenscapCard', () => { + beforeEach(() => { + API.put.mockClear(); + useAPI.mockReset(); + mockUseAPI(apiFixtures); + }); + + it('renders status, rule results, latest report and policy count', () => { + renderCard(); + + expect(screen.getByRole('heading', { name: 'Compliance' })).toBeVisible(); + expect(screen.getByText('Incompliant')).toBeVisible(); + expect(screen.getByText('10 passed')).toBeVisible(); + expect(screen.getByText('2 failed')).toBeVisible(); + expect(screen.getByText('1 other')).toBeVisible(); + expect(screen.getByText('3 assigned')).toBeVisible(); + expect( + screen.getByRole('link', { name: latestReport['reported_at'] }) + ).toHaveAttribute('href', '/compliance/arf_reports/99'); + expect(useAPI).toHaveBeenCalledWith( + 'get', + '/api/v2/compliance/arf_reports?search=host_id%20%3D%2042%20and%20last_for%20%3D%20host', + { + key: 'OPENSCAP_HOST_REPORTS_42', + } + ); + }); + + it('shows N/A when no policy is assigned', () => { + mockUseAPI({ + ...apiFixtures, + policiesEnc: [], + }); + + renderCard(); + + expect(screen.getByText('N/A')).toBeVisible(); + expect(screen.getByText('0 assigned')).toBeVisible(); + }); + + it('lists policy sources and only allows unsetting direct host policies', () => { + renderCard(); + + fireEvent.click(screen.getByRole('button', { name: '3 assigned' })); + + expect(screen.getByText('Direct policy')).toBeVisible(); + expect(screen.getByText('Source: Direct host')).toBeVisible(); + expect(screen.getByText('Host group policy')).toBeVisible(); + expect( + screen.getByText('Source: Host group: Parent > Child') + ).toBeVisible(); + expect(screen.getByText('Parent host group policy')).toBeVisible(); + expect(screen.getByText('Source: Parent host group: Parent')).toBeVisible(); + expect(screen.getAllByRole('button', { name: 'Unset' })).toHaveLength(1); + }); + + it('unsets direct host policies without changing other direct hosts', async () => { + mockUseAPI({ + ...apiFixtures, + policies: { + results: [ + { + ...assignedPolicies[0], + hosts: [ + { id: 42, name: 'host.example.com' }, + { id: 100, name: 'other.example.com' }, + ], + }, + assignedPolicies[1], + assignedPolicies[2], + unassignedPolicy, + ], + }, + }); + + renderCard(); + + fireEvent.click(screen.getByRole('button', { name: '3 assigned' })); + fireEvent.click(screen.getByRole('button', { name: 'Unset' })); + + await waitFor(() => + expect(API.put).toHaveBeenCalledWith('/api/v2/compliance/policies/1', { + policy: { host_ids: [100] }, + }) + ); + }); + + it('sets a selected policy directly on the host', async () => { + renderCard(); + + fireEvent.click(screen.getByRole('button', { name: '3 assigned' })); + fireEvent.change(screen.getByLabelText('Select policy'), { + target: { value: '4' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + + await waitFor(() => + expect(API.put).toHaveBeenCalledWith('/api/v2/compliance/policies/4', { + policy: { host_ids: [100, 42] }, + }) + ); + }); +}); diff --git a/webpack/components/HostDetails/OpenscapCard/PolicyModal.js b/webpack/components/HostDetails/OpenscapCard/PolicyModal.js new file mode 100644 index 00000000..93992aad --- /dev/null +++ b/webpack/components/HostDetails/OpenscapCard/PolicyModal.js @@ -0,0 +1,223 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + Button, + DataList, + DataListAction, + DataListCell, + DataListItem, + DataListItemRow, + Flex, + FlexItem, + Form, + FormGroup, + FormSelect, + FormSelectOption, + Modal, + ModalVariant, + Spinner, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { API } from 'foremanReact/redux/API'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; + +import { policyHostIds, sourceForPolicy, toNumber, uniqueIds } from './helpers'; + +const PolicyModal = ({ + allPolicies, + assignedPolicies, + availablePolicies, + hostDetails, + hostId, + isLoading, + isOpen, + onClose, + onRefresh, +}) => { + const [selectedPolicyId, setSelectedPolicyId] = useState(''); + const [savingPolicyId, setSavingPolicyId] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + const updatePolicyHosts = async (policy, hostIds) => { + setErrorMessage(''); + setSavingPolicyId(toNumber(policy.id)); + + try { + await API.put(`/api/v2/compliance/policies/${policy.id}`, { + policy: { host_ids: uniqueIds(hostIds) }, + }); + setSelectedPolicyId(''); + onRefresh(); + } catch (error) { + setErrorMessage( + error?.response?.data?.error?.message || + error?.message || + __('Unable to update policy assignment.') + ); + } finally { + setSavingPolicyId(null); + } + }; + + const assignPolicy = () => { + const policy = allPolicies.find( + currentPolicy => toNumber(currentPolicy.id) === toNumber(selectedPolicyId) + ); + + if (policy) { + updatePolicyHosts(policy, [...policyHostIds(policy), hostId]); + } + }; + + const unsetPolicy = policy => { + updatePolicyHosts( + policy, + policyHostIds(policy).filter(id => toNumber(id) !== toNumber(hostId)) + ); + }; + + const actions = [ + , + ]; + + return ( + + {errorMessage && ( + + )} + {isLoading ? ( + + ) : ( + + + + {assignedPolicies.length === 0 && ( + + + + {__('No policies assigned')} + + + + )} + {assignedPolicies.map(policy => { + const source = sourceForPolicy(policy, hostDetails); + return ( + + + + + {policy.name || sprintf(__('Policy %s'), policy.id)} + +
+ {sprintf(__('Source: %s'), source.label)} +
+ + {source.isDirect && ( + + )} + +
+
+ ); + })} +
+
+ +
+ + + + setSelectedPolicyId(value)} + aria-label={__('Select policy')} + isDisabled={Boolean(savingPolicyId)} + > + + {availablePolicies.map(policy => ( + + ))} + + + + + + + +
+
+
+ )} +
+ ); +}; + +PolicyModal.propTypes = { + allPolicies: PropTypes.arrayOf(PropTypes.object).isRequired, + assignedPolicies: PropTypes.arrayOf(PropTypes.object).isRequired, + availablePolicies: PropTypes.arrayOf(PropTypes.object).isRequired, + hostDetails: PropTypes.object.isRequired, + hostId: PropTypes.number.isRequired, + isLoading: PropTypes.bool.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onRefresh: PropTypes.func.isRequired, +}; + +export default PolicyModal; diff --git a/webpack/components/HostDetails/OpenscapCard/helpers.js b/webpack/components/HostDetails/OpenscapCard/helpers.js new file mode 100644 index 00000000..77abf28f --- /dev/null +++ b/webpack/components/HostDetails/OpenscapCard/helpers.js @@ -0,0 +1,86 @@ +import { translate as __, sprintf } from 'foremanReact/common/I18n'; + +export const COMPLIANCE_STATUS = { + COMPLIANT: 0, + INCONCLUSIVE: 1, + INCOMPLIANT: 2, +}; + +export const policyHostIds = policy => (policy.hosts || []).map(({ id }) => id); + +export const toNumber = value => Number(value); + +export const collectionResults = response => { + if (Array.isArray(response)) return response; + return response?.results || []; +}; + +export const uniqueIds = ids => [...new Set(ids.map(toNumber).filter(Boolean))]; + +export const formatHostgroupTitle = title => + (title || '').replace(/\//g, ' > '); + +export const statusColor = status => { + switch (status) { + case COMPLIANCE_STATUS.COMPLIANT: + return 'green'; + case COMPLIANCE_STATUS.INCONCLUSIVE: + return 'orange'; + case COMPLIANCE_STATUS.INCOMPLIANT: + return 'red'; + default: + return 'grey'; + } +}; + +export const sourceForPolicy = (policy, hostDetails) => { + const hostId = toNumber(hostDetails.id); + const hostgroupId = toNumber(hostDetails.hostgroup_id); + const hostgroupTitle = hostDetails.hostgroup_title || ''; + const hostgroups = policy.hostgroups || []; + + if ((policy.hosts || []).some(host => toNumber(host.id) === hostId)) { + return { label: __('Direct host'), isDirect: true }; + } + + const assignedHostgroup = hostgroups.find( + hostgroup => toNumber(hostgroup.id) === hostgroupId + ); + if (assignedHostgroup) { + return { + label: sprintf( + __('Host group: %s'), + formatHostgroupTitle(assignedHostgroup.title || assignedHostgroup.name) + ), + isDirect: false, + }; + } + + const inheritedHostgroup = hostgroups.reduce((bestMatch, hostgroup) => { + const title = hostgroup.title || hostgroup.name || ''; + if (hostgroupTitle !== title && !hostgroupTitle.startsWith(`${title}/`)) { + return bestMatch; + } + + if (!bestMatch) { + return hostgroup; + } + + const bestTitle = bestMatch.title || bestMatch.name || ''; + return title.length > bestTitle.length ? hostgroup : bestMatch; + }, null); + + if (inheritedHostgroup) { + return { + label: sprintf( + __('Parent host group: %s'), + formatHostgroupTitle( + inheritedHostgroup.title || inheritedHostgroup.name + ) + ), + isDirect: false, + }; + } + + return { label: __('Host group'), isDirect: false }; +}; diff --git a/webpack/components/HostDetails/OpenscapCard/index.js b/webpack/components/HostDetails/OpenscapCard/index.js new file mode 100644 index 00000000..3e72941d --- /dev/null +++ b/webpack/components/HostDetails/OpenscapCard/index.js @@ -0,0 +1,215 @@ +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Label, + Spinner, +} from '@patternfly/react-core'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import CardTemplate from 'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate'; +import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime'; + +import PolicyModal from './PolicyModal'; +import { + collectionResults, + formatHostgroupTitle, + statusColor, + toNumber, + uniqueIds, +} from './helpers'; +import './OpenscapCard.scss'; + +export { formatHostgroupTitle }; + +const OpenscapCard = ({ hostDetails }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + const hostId = toNumber(hostDetails.id); + const reportsSearch = `host_id = ${hostId} and last_for = host`; + const reportsUrl = `/api/v2/compliance/arf_reports?search=${encodeURIComponent( + reportsSearch + )}`; + const policiesEncUrl = `/api/v2/hosts/${hostId}/policies_enc`; + const policiesUrl = isModalOpen ? '/api/v2/compliance/policies' : null; + + const { response: reportsResponse, status: reportsStatus } = useAPI( + 'get', + reportsUrl, + { + key: `OPENSCAP_HOST_REPORTS_${hostId}`, + } + ); + + const { response: policiesEncResponse, status: policiesEncStatus } = useAPI( + 'get', + policiesEncUrl, + { + key: `OPENSCAP_HOST_POLICIES_ENC_${hostId}_${reloadToken}`, + } + ); + + const { response: policiesResponse, status: policiesStatus } = useAPI( + 'get', + policiesUrl, + { + key: `OPENSCAP_POLICIES_${hostId}_${reloadToken}`, + params: { per_page: 9999 }, + } + ); + + const reports = collectionResults(reportsResponse); + const latestReport = reports[0]; + const hostPolicies = collectionResults(policiesEncResponse); + const allPolicies = collectionResults(policiesResponse); + const assignedPolicyIds = uniqueIds(hostPolicies.map(({ id }) => id)); + + const assignedPolicies = useMemo( + () => + assignedPolicyIds.map( + id => allPolicies.find(policy => toNumber(policy.id) === id) || { id } + ), + [allPolicies, assignedPolicyIds] + ); + + const availablePolicies = allPolicies.filter( + policy => !assignedPolicyIds.includes(toNumber(policy.id)) + ); + + const isLoadingPolicies = + isModalOpen && (!policiesStatus || policiesStatus === 'PENDING'); + const isLoadingHostPolicies = + !policiesEncStatus || policiesEncStatus === 'PENDING'; + const hasAssignedPolicies = assignedPolicyIds.length > 0; + const statusLabel = + !isLoadingHostPolicies && !hasAssignedPolicies + ? __('N/A') + : hostDetails.compliance_status_label || __('Unknown Compliance status'); + const complianceStatus = hasAssignedPolicies + ? hostDetails.compliance_status + : undefined; + const latestReportLink = latestReport?.id + ? foremanUrl(`/compliance/arf_reports/${latestReport.id}`) + : undefined; + const latestReportReportedAt = latestReport?.['reported_at']; + + return ( + <> + + + + {__('Status')} + + + + + + {__('Rule results')} + + {latestReport ? ( + + + + + + + + + + + + ) : ( + __('No report') + )} + + + + {__('Latest report')} + + + + + + {__('Policies')} + + + + + + + + setIsModalOpen(false)} + onRefresh={() => setReloadToken(token => token + 1)} + /> + + ); +}; + +OpenscapCard.propTypes = { + hostDetails: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + compliance_status: PropTypes.number, + compliance_status_label: PropTypes.string, + hostgroup_id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + hostgroup_title: PropTypes.string, + }), +}; + +OpenscapCard.defaultProps = { + hostDetails: {}, +}; + +export default OpenscapCard; diff --git a/webpack/global_index.js b/webpack/global_index.js index d62e51fa..d65bd95e 100644 --- a/webpack/global_index.js +++ b/webpack/global_index.js @@ -1,6 +1,7 @@ import React from 'react'; import { addGlobalFill } from 'foremanReact/components/common/Fill/GlobalFill'; import HostKebabItems from './components/HostExtentions/HostKebabItems'; +import OpenscapCard from './components/HostDetails/OpenscapCard'; addGlobalFill( 'host-details-kebab', @@ -8,3 +9,10 @@ addGlobalFill( , 400 ); + +addGlobalFill( + 'host-overview-cards', + 'openscap-compliance-card', + , + 2800 +);