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 ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+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
+);