From a90955964d96a5f8a5bf924a79432ce5946f723f Mon Sep 17 00:00:00 2001 From: Bernhard Suttner Date: Tue, 7 Jul 2026 23:24:17 +0200 Subject: [PATCH] Add OpenSCAP compliance card to host details Add a Compliance card to the new host details overview using the existing OpenSCAP APIs. The card shows the compliance status, latest rule result counts, latest ARF report link, and assigned policy count. Add a policy management modal that lists resolved policies with their source, allows unsetting direct host policy assignments, and allows assigning an available policy directly to the host. Keep hostgroup and inherited policies read-only because the existing API only supports direct host assignment from this context. Co-authored-by: OpenAI Codex --- .../OpenscapCard/OpenscapCard.scss | 4 + .../OpenscapCard/OpenscapCard.test.js | 252 ++++++++++++++++++ .../HostDetails/OpenscapCard/PolicyModal.js | 223 ++++++++++++++++ .../HostDetails/OpenscapCard/helpers.js | 86 ++++++ .../HostDetails/OpenscapCard/index.js | 215 +++++++++++++++ webpack/global_index.js | 8 + 6 files changed, 788 insertions(+) create mode 100644 webpack/components/HostDetails/OpenscapCard/OpenscapCard.scss create mode 100644 webpack/components/HostDetails/OpenscapCard/OpenscapCard.test.js create mode 100644 webpack/components/HostDetails/OpenscapCard/PolicyModal.js create mode 100644 webpack/components/HostDetails/OpenscapCard/helpers.js create mode 100644 webpack/components/HostDetails/OpenscapCard/index.js diff --git a/webpack/components/HostDetails/OpenscapCard/OpenscapCard.scss b/webpack/components/HostDetails/OpenscapCard/OpenscapCard.scss new file mode 100644 index 000000000..9c6c7851c --- /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 000000000..90a6adfa4 --- /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 000000000..93992aad3 --- /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 000000000..77abf28f9 --- /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 000000000..3e72941dc --- /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 d62e51fa3..d65bd95ed 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 +);