diff --git a/backend/src/resources/resource.ts b/backend/src/resources/resource.ts index bb5f5e0c3ce..bcdcb981be1 100644 --- a/backend/src/resources/resource.ts +++ b/backend/src/resources/resource.ts @@ -43,6 +43,7 @@ interface OwnerReference { export interface IResource { kind: string apiVersion: string + throttled?: boolean metadata?: { name: string namespace?: string diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 0b4eb6c833a..b3edbcf5f58 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -53,10 +53,192 @@ export interface SettingsEvent { settings: Record } -type ServerSideEventData = WatchEvent | SettingsEvent | { type: 'START' | 'LOADED' } +type ServerSideEventData = WatchEvent | SettingsEvent | { type: 'START' | 'LOADED' | 'EOP' } let requests: { cancel: () => void }[] = [] +/** + * Policy flap throttling: limits cache/SSE churn when Policies update too often. + * More than FLAP_THRESHOLD updates in FLAP_WINDOW_MS (after FLAP_SETTLING_MS) sets `throttled` and + * allows at most one cached update per FLAP_COOLDOWN_MS. Throttle clears on spec change or after P + * with no updates (see startMonitoringThrottled). Overridable via FLAP_* / THROTTLING_CHECK_INTERVAL env vars. + */ +export const FLAP_THRESHOLD = Number(process.env.FLAP_THRESHOLD) || 5 // N: updates within M that trigger throttling +export const FLAP_WINDOW_MS = Number(process.env.FLAP_WINDOW_MS) || 60 * 1000 // M: sliding window for counting calls +export const FLAP_COOLDOWN_MS = Number(process.env.FLAP_COOLDOWN_MS) || 60 * 1000 // P: min interval between allowed updates; silence to exit +export const FLAP_SETTLING_MS = Number(process.env.FLAP_SETTLING_MS) || 60 * 1000 // S: grace period before marking resource.throttled +const FLAP_TRACKER_TTL_MS = 12 * 60 * 60 * 1000 // drop tracker entry 12h after resource first seen (emerged) +const THROTTLING_CHECK_INTERVAL = Number(process.env.THROTTLING_CHECK_INTERVAL) || 60 * 1000 + +interface FlapTrackerEntry { + timestamps: number[] + lastCachedAt: number + emerged: number + throttled?: boolean + lastSpec?: string + resource?: string +} + +const flapTracker: Record = {} + +/** Clear flap tracker state. Used for test isolation. */ +export function resetFlapTracker(): void { + for (const key in flapTracker) { + delete flapTracker[key] + } +} + +export function getFlapTracker() { + return flapTracker +} + +export function resourceFlapKey( + resource: Pick & { metadata?: { namespace?: string; name?: string } } +) { + return `${resource.kind}/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}` +} + +function resourceSpecKey(resource: IResource): string { + return JSON.stringify(get(resource, 'spec') ?? {}) +} + +export function formatFlappingMessage(kind: string, namespace: string, name: string): string { + const windowMinutes = Math.max(1, Math.round(FLAP_WINDOW_MS / 60_000)) + const timesPerMinute = Math.max(1, Math.round(60_000 / FLAP_COOLDOWN_MS)) + return `${kind} ${name} in namespace ${namespace} has been modified more than ${FLAP_THRESHOLD} times in the last ${windowMinutes} minutes. Verify this resource is configured correctly. Updates are being limited to ${timesPerMinute} times per minute.` +} + +export function formatFlappingRecoveredMessage(kind: string, namespace: string, name: string): string { + return `${kind} ${name} in namespace ${namespace} is no longer being throttled; policy updates will resume normally.` +} + +// +// If a watched resource has too many updates pre minute +// put it into a polling mode where it just allows one update per minute +// +export function shouldThrottleResource(resource: IResource, now = Date.now()): boolean { + if (resource.kind !== 'Policy') { + return false + } + + // every resource is tracked + const key = resourceFlapKey(resource) + let entry = flapTracker[key] + if (!entry) { + entry = { + timestamps: [], + lastCachedAt: 0, + emerged: now, + } + flapTracker[key] = entry + } + + // if resource's spec has changed, immediately remove from polling mode + const specKey = resourceSpecKey(resource) + if (entry.lastSpec !== undefined && entry.lastSpec !== specKey) { + delete entry.resource + delete entry.throttled + delete entry.lastSpec + return false + } else { + // else determine if resource is flapping if it updates more then FLAP_THRESHOLD in FLAP_WINDOW_MS ms + entry.timestamps.push(now) + entry.timestamps = entry.timestamps.filter((t) => now - t <= FLAP_WINDOW_MS) + if (entry.timestamps.length > FLAP_THRESHOLD) { + // when a resource is first created, it might flap at first + // so allow a settling time before actually throttling + if (now - entry.emerged > FLAP_SETTLING_MS) { + if (!entry.throttled) { + logger.warn({ + msg: formatFlappingMessage( + resource.kind, + resource.metadata?.namespace ?? '', + resource.metadata?.name ?? '' + ), + }) + } + entry.resource = JSON.stringify(resource) + resource.throttled = true + entry.throttled = true + } + } + } + entry.lastSpec = specKey + + // if resource is in polling mode, allow one update per cooldown interval; suppress all others while polling. + if (entry.throttled) { + // Allow one update per cooldown interval; suppress all others while polling. + if (entry.lastCachedAt === 0 || now - entry.lastCachedAt >= FLAP_COOLDOWN_MS) { + entry.lastCachedAt = now + } else { + return true + } + } else { + entry.lastCachedAt = 0 + } + + return false +} + +// +// Periodically check resources that are throttled to +// see if they are still flapping and if not, reset +// +let monitoringThrottledTimer: NodeJS.Timeout | undefined + +export async function checkThrottleStatus(now = Date.now()): Promise { + for (const key of Object.keys(flapTracker)) { + const entry = flapTracker[key] + if (now - entry.emerged > FLAP_TRACKER_TTL_MS) { + delete flapTracker[key] + continue + } + if (!entry.throttled) { + continue + } + if (entry.timestamps.length > 0) { + const lastCall = entry.timestamps[entry.timestamps.length - 1] + if (now - lastCall > FLAP_COOLDOWN_MS) { + const resource = entry.resource ? (JSON.parse(entry.resource) as IResource) : undefined + if (resource) { + await cacheResource(resource, true) + logger.warn({ + msg: formatFlappingRecoveredMessage( + resource.kind, + resource.metadata?.namespace ?? '', + resource.metadata?.name ?? '' + ), + }) + } + delete entry.resource + entry.throttled = false + entry.lastCachedAt = 0 + } + } + } +} + +function startMonitoringThrottled(): void { + if (monitoringThrottledTimer) return + + monitoringThrottledTimer = setInterval(() => { + void checkThrottleStatus().catch((err: unknown) => { + logger.error({ msg: 'throttling check failed', error: err }) + }) + }, THROTTLING_CHECK_INTERVAL) + + monitoringThrottledTimer.unref() + logger.info({ msg: 'throttling check started', interval: THROTTLING_CHECK_INTERVAL }) +} + +function stopMonitoringThrottled(): void { + if (monitoringThrottledTimer) { + clearInterval(monitoringThrottledTimer) + monitoringThrottledTimer = undefined + logger.info({ msg: 'monitoring throttled stopped' }) + } +} + export async function getKubeResources(kind: string, apiVersion: string) { const option = { apiVersion, kind } const apiVersionPlural = apiVersionPluralFn(option) @@ -186,6 +368,7 @@ export function getEventDict() { export function startWatching(): void { ServerSideEvents.eventFilter = eventFilter startAccessCacheCleanup() + startMonitoringThrottled() for (const definition of definitions) { void listAndWatch(definition) @@ -411,6 +594,14 @@ export function createWatchEventProcessor(options: IWatchOptions, url: string, r throw err } pruneResources(options, [watchEvent.object]) + // Track flapping Policy updates but skip caching/broadcasting suppressed events. + if ( + (watchEvent.type === 'ADDED' || watchEvent.type === 'MODIFIED') && + shouldThrottleResource(watchEvent.object) + ) { + callback() + return + } switch (watchEvent.type) { case 'ADDED': case 'MODIFIED': @@ -787,6 +978,7 @@ let stopping = false export function stopWatching(): void { stopping = true stopAccessCacheCleanup() + stopMonitoringThrottled() for (const request of requests) { request.cancel() } diff --git a/backend/test/routes/events.test.ts b/backend/test/routes/events.test.ts index f02d20614ad..bd759c13817 100644 --- a/backend/test/routes/events.test.ts +++ b/backend/test/routes/events.test.ts @@ -15,6 +15,22 @@ import { createWatchEventProcessor, listAndWatch, stopWatching, + canAccess, + resetAccessCache, + getAccessCache, + cleanupAccessCache, + ACCESS_CACHE_TTL, + ACCESS_CACHE_MAX_TOKENS, + shouldThrottleResource, + checkThrottleStatus, + resetFlapTracker, + getFlapTracker, + formatFlappingMessage, + FLAP_THRESHOLD, + FLAP_WINDOW_MS, + FLAP_COOLDOWN_MS, + FLAP_SETTLING_MS, + resetResourceCache, } from '../../src/routes/events' import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' import type { IArgoApplication, IResource } from '../../src/resources/resource' @@ -1405,4 +1421,284 @@ describe('events Route', () => { expect(listCallCount).toBe(1) // Only counting second list call }) }) + + describe('Access Cache Cleanup', () => { + beforeEach(() => { + resetAccessCache() + jest.clearAllMocks() + process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' + }) + + afterEach(() => { + resetAccessCache() + delete process.env.CLUSTER_API_URL + nock.cleanAll() + }) + + it('should cache RBAC access check results', async () => { + const mockToken = 'test-token-123' + const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + const result1 = await canAccess(resource, 'get', mockToken) + const result2 = await canAccess(resource, 'get', mockToken) + + expect(result1).toBe(true) + expect(result1).toBe(result2) + }) + + it('should respect TTL and refetch after expiry', async () => { + const cache = getAccessCache() + const mockToken = 'test-token-ttl' + + cache[mockToken] = { + 'Secret:default:credentials': { time: Date.now() - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, + } + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + const result = await canAccess( + { kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default', name: 'credentials' } }, + 'get', + mockToken + ) + expect(result).toBe(false) + }) + + it('should remove stale cache entries during cleanup', () => { + const cache = getAccessCache() + const now = Date.now() + + cache['token1'] = { + stale: { time: now - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, + fresh: { time: now - 30000, promise: Promise.resolve(true) }, + } + cache['token2'] = { 'stale-only': { time: now - ACCESS_CACHE_TTL - 5000, promise: Promise.resolve(false) } } + + cleanupAccessCache() + + expect(cache['token1']['stale']).toBeUndefined() + expect(cache['token1']['fresh']).toBeDefined() + expect(cache['token2']).toBeUndefined() + }) + + it('should enforce maximum token limit with LRU eviction', () => { + const cache = getAccessCache() + const now = Date.now() + const tokenCount = ACCESS_CACHE_MAX_TOKENS + 100 + + for (let i = 0; i < tokenCount; i++) { + cache[`token-${i}`] = { + 'Pod:default:test': { time: now - (i / tokenCount) * 50 * 1000, promise: Promise.resolve(true) }, + } + } + + cleanupAccessCache() + + expect(Object.keys(cache).length).toBe(ACCESS_CACHE_MAX_TOKENS) + expect(cache['token-0']).toBeDefined() + expect(cache[`token-${tokenCount - 1}`]).toBeUndefined() + }) + }) + + describe('flapping resource throttling', () => { + const compliantValues = ['Compliant', 'NonCompliant', 'Pending'] as const + const policyApiVersion = 'policy.open-cluster-management.io/v1' + + function policyWithCompliant(name: string, namespace: string, changeIndex: number): IResource { + return { + kind: 'Policy', + apiVersion: policyApiVersion, + metadata: { name, namespace, uid: `${namespace}-${name}` }, + status: { compliant: compliantValues[changeIndex % compliantValues.length] }, + } as IResource + } + + function throttlePolicyAt(name: string, namespace: string, atTime: number) { + shouldThrottleResource(policyWithCompliant(name, namespace, 0), atTime - FLAP_SETTLING_MS - 100) + for (let i = 0; i <= FLAP_THRESHOLD; i++) { + shouldThrottleResource(policyWithCompliant(name, namespace, i + 1), atTime - (FLAP_THRESHOLD - i) * 100) + } + expect(getFlapTracker()[`Policy/${namespace}/${name}`].throttled).toBe(true) + } + + beforeEach(() => { + resetFlapTracker() + resetResourceCache() + ServerSideEvents.reset() + }) + + afterEach(() => { + resetFlapTracker() + resetResourceCache() + ServerSideEvents.reset() + }) + + it('should format the flapping warning message using cooldown rate', () => { + const windowMinutes = Math.max(1, Math.round(FLAP_WINDOW_MS / 60_000)) + const timesPerMinute = Math.max(1, Math.round(60_000 / FLAP_COOLDOWN_MS)) + expect(formatFlappingMessage('Policy', 'default', 'policy-a')).toBe( + `Policy policy-a in namespace default has been modified more than ${FLAP_THRESHOLD} times in the last ${windowMinutes} minutes. Verify this resource is configured correctly. Updates are being limited to ${timesPerMinute} times per minute.` + ) + }) + + it('should reset flap tracker when policy spec changes', () => { + const base = Date.now() + const policy = { + ...policyWithCompliant('spec-change', 'default', 0), + spec: { disabled: false }, + } as IResource + + throttlePolicyAt('spec-change', 'default', base + FLAP_SETTLING_MS + FLAP_THRESHOLD + 1) + + expect( + shouldThrottleResource( + { ...policy, spec: { disabled: true } } as IResource, + base + FLAP_SETTLING_MS + FLAP_THRESHOLD + 2 + ) + ).toBe(false) + const entry = getFlapTracker()['Policy/default/spec-change'] + expect(entry.throttled).toBeUndefined() + expect(entry.lastSpec).toBeUndefined() + }) + + it('should never throttle non-Policy kinds', () => { + const now = Date.now() + for (let i = 0; i < FLAP_THRESHOLD + 10; i++) { + expect( + shouldThrottleResource( + { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name: 'cluster-a', namespace: '' }, + }, + now + i + ) + ).toBe(false) + } + }) + + it('should only set throttled on the tracker after settling while polling', () => { + const base = Date.now() + + for (let i = 0; i <= FLAP_THRESHOLD; i++) { + shouldThrottleResource(policyWithCompliant('policy-a', 'default', i), base + i) + expect(getFlapTracker()['Policy/default/policy-a'].throttled).toBeUndefined() + } + + const afterSettling = base + FLAP_SETTLING_MS + 1 + shouldThrottleResource(policyWithCompliant('policy-a', 'default', FLAP_THRESHOLD + 1), afterSettling) + expect(getFlapTracker()['Policy/default/policy-a'].throttled).toBe(true) + }) + + it('should throttle Policy updates after more than N compliant changes within M seconds', () => { + const atTime = Date.now() + throttlePolicyAt('flappy', 'default', atTime) + + expect(shouldThrottleResource(policyWithCompliant('flappy', 'default', 99), atTime + 100)).toBe(true) + expect(shouldThrottleResource(policyWithCompliant('flappy', 'default', 100), atTime + FLAP_COOLDOWN_MS)).toBe( + false + ) + }) + + it('should allow caching again after FLAP_COOLDOWN_MS while still throttled', () => { + const base = Date.now() + const throttledAt = base + FLAP_SETTLING_MS + FLAP_THRESHOLD * 100 + throttlePolicyAt('periodic', 'default', throttledAt) + + expect( + shouldThrottleResource(policyWithCompliant('periodic', 'default', FLAP_THRESHOLD + 2), throttledAt + 100) + ).toBe(true) + + const periodicAt = throttledAt + FLAP_COOLDOWN_MS + expect(shouldThrottleResource(policyWithCompliant('periodic', 'default', FLAP_THRESHOLD + 3), periodicAt)).toBe( + false + ) + }) + + it('should continue suppressing caches after detection window clears until cooldown expires', () => { + const base = Date.now() + const throttledAt = base + FLAP_SETTLING_MS + FLAP_THRESHOLD * 100 + throttlePolicyAt('sticky', 'default', throttledAt) + + expect( + shouldThrottleResource(policyWithCompliant('sticky', 'default', FLAP_THRESHOLD + 2), throttledAt + 100) + ).toBe(true) + expect(getFlapTracker()['Policy/default/sticky'].throttled).toBe(true) + }) + + it('should stop throttling when modifications fall back and cooldown expires', async () => { + const base = Date.now() + const throttledAt = base + FLAP_SETTLING_MS + FLAP_THRESHOLD * 100 + throttlePolicyAt('recovering', 'ns1', throttledAt) + + const afterCooldown = throttledAt + FLAP_COOLDOWN_MS + 1 + await checkThrottleStatus(afterCooldown) + expect(getFlapTracker()['Policy/ns1/recovering'].throttled).toBe(false) + expect(getFlapTracker()['Policy/ns1/recovering'].lastCachedAt).toBe(0) + }) + + it('should cache at most once per minute while a Policy is flapping', async () => { + const pushSpy = jest.spyOn(ServerSideEvents, 'pushEvent') + const base = 1_000_000_000_000 + const throttledAt = base + FLAP_SETTLING_MS + FLAP_THRESHOLD * 100 + let revision = 0 + + const makeResource = (): IResource => + ({ + kind: 'Policy', + apiVersion: policyApiVersion, + metadata: { + name: 'flappy-policy', + namespace: 'default', + uid: 'flappy-uid', + resourceVersion: String(++revision), + }, + status: { compliant: compliantValues[revision % compliantValues.length] }, + }) as IResource + + throttlePolicyAt('flappy-policy', 'default', throttledAt) + + for (let i = 0; i <= 2; i++) { + const resource = makeResource() + if (!shouldThrottleResource(resource, throttledAt + 100 + i)) { + await cacheResource(resource) + } + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + for (const entry of Object.values(getEventCache())) { + await Promise.all(Object.values(entry).map((e) => e.eventID)) + } + + let modifiedPushes = pushSpy.mock.calls.filter((call) => (call[0].data as { type?: string })?.type === 'MODIFIED') + const pushesAfterFlapping = modifiedPushes.length + + const periodicAt = throttledAt + FLAP_COOLDOWN_MS + const periodicResource = makeResource() + if (!shouldThrottleResource(periodicResource, periodicAt)) { + await cacheResource(periodicResource) + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + for (const entry of Object.values(getEventCache())) { + await Promise.all(Object.values(entry).map((e) => e.eventID)) + } + + modifiedPushes = pushSpy.mock.calls.filter((call) => (call[0].data as { type?: string })?.type === 'MODIFIED') + expect(modifiedPushes.length).toBe(pushesAfterFlapping + 1) + + const resources = await getKubeResources('Policy', policyApiVersion) + expect(resources).toHaveLength(1) + expect(resources[0].metadata.resourceVersion).toBe(String(revision)) + expect(getFlapTracker()['Policy/default/flappy-policy'].throttled).toBe(true) + + pushSpy.mockRestore() + }) + }) }) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index e58117bb81d..aa43ba5ddc1 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -2580,6 +2580,7 @@ "Policy automation steps": "Policy automation steps", "Policy cluster violations": "Policy cluster violations", "Policy cluster violations chart": "Policy cluster violations chart", + "Policy conflicts": "Policy conflicts", "Policy content": "Policy content", "Policy created": "Policy created", "Policy details": "Policy details", @@ -2626,6 +2627,7 @@ "policy.modal.warning.pruneParameter": "Some policies have the Prune parameter set.", "policy.modal.warning.pruneParameter.deleteMessage": "Deleting this policy might delete some related objects on the managed cluster(s).", "policy.modal.warning.pruneParameter.disableMessage": "Disabling this policy might delete some related objects on the managed cluster(s).", + "policy.overview.conflicts.tooltip": "The policies in this list are changing their compliances too rapidly. This can happen if:\n\n• A user policy may have a defect.\n\n• Two or more policies are trying to enforce different values for the same resource.\n\nUntil this is resolved, these policy updates will be throttled to once a minute.", "policy.table.actionGroup.status": "Status", "policy.table.actionGroup.status.disabled": "Disabled", "policy.table.actionGroup.status.enabled": "Enabled", @@ -2638,6 +2640,8 @@ "policy.table.actions.enforcing": "Enforcing", "policy.table.actions.inform": "Inform", "policy.table.actions.informing": "Informing", + "policy.table.throttled": "Updates throttled", + "policy.table.throttled.tooltip": "This policy's compliance is changing too frequently. See Policy conflicts panel in Overview", "policy.tableHeader.name": "Policy name", "policy.violations_one": "{{count}} policy with violations", "policy.violations_other": "{{count}} policies with violations", diff --git a/frontend/src/resources/policy.ts b/frontend/src/resources/policy.ts index cd2483fc9fb..f0d5acf7d76 100644 --- a/frontend/src/resources/policy.ts +++ b/frontend/src/resources/policy.ts @@ -42,6 +42,7 @@ export interface Policy { } // This not from API, this will be added at console remediationResult?: REMEDIATION_ACTION | string + throttled?: boolean } export interface PolicyTemplate { diff --git a/frontend/src/routes/Governance/common/useCustom.tsx b/frontend/src/routes/Governance/common/useCustom.tsx index e099a5dd339..9c82381bae8 100644 --- a/frontend/src/routes/Governance/common/useCustom.tsx +++ b/frontend/src/routes/Governance/common/useCustom.tsx @@ -18,7 +18,7 @@ export function useAddRemediationPolicies() { (p: Policy) => p.metadata.name === `${policyNamespace}.${policyName}` ) const result = cloneDeep(p) - result.remediationResult = getPolicyRemediation(p, matchedPropagated) + result.remediationResult = getPolicyRemediation(result, matchedPropagated) return result }) return resultPolicies diff --git a/frontend/src/routes/Governance/common/util.tsx b/frontend/src/routes/Governance/common/util.tsx index 0c185e58462..0d36b9a0d92 100644 --- a/frontend/src/routes/Governance/common/util.tsx +++ b/frontend/src/routes/Governance/common/util.tsx @@ -473,6 +473,11 @@ export function getPolicyRemediation(policy: Policy | undefined, propagatedPolic if (!policy) { return '' } + if (propagatedPolicies.some((propaPolicy) => propaPolicy.throttled === true)) { + policy.throttled = true + } else { + delete policy.throttled + } const templates = policy.spec['policy-templates'] let rootRA = policy.spec.remediationAction || '' let remediationAggregation = '' diff --git a/frontend/src/routes/Governance/overview/Overview.tsx b/frontend/src/routes/Governance/overview/Overview.tsx index d39f2e15e7c..b534c3bb987 100644 --- a/frontend/src/routes/Governance/overview/Overview.tsx +++ b/frontend/src/routes/Governance/overview/Overview.tsx @@ -8,10 +8,13 @@ import { ExpandableSection, Icon, PageSection, + Popover, + PopoverPosition, Stack, Tooltip, } from '@patternfly/react-core' import { CheckCircleIcon, ExclamationCircleIcon, ExclamationTriangleIcon } from '@patternfly/react-icons' +import { generatePath } from 'react-router' import { Fragment, useCallback, useContext, useMemo, useState } from 'react' import { AcmMasonry } from '../../../components/AcmMasonry' import { useTranslation } from '../../../lib/acm-i18next' @@ -26,7 +29,16 @@ import { import { ClusterPolicySummarySidebar } from './ClusterPolicySummarySidebar' import { useClusterViolationSummaryMap } from './ClusterViolationSummary' import { PolicySetViolationsCard } from './PolicySetViolationSummary' -import { PolicyViolationsCard, usePolicyViolationSummary, ViolationSummary } from './PolicyViolationSummary' +import { + PolicyViolationsCard, + usePolicyClusterViolationSummaryMap, + usePolicyViolationSummary, + ViolationSummary, +} from './PolicyViolationSummary' +import { useAddRemediationPolicies } from '../common/useCustom' +import { NavigationPath } from '../../../NavigationPath' +import { AcmVisitedLink } from '../../../ui-components' +import { ClusterPolicyViolationIcons2 } from '../components/ClusterPolicyViolations' import { SecurityGroupPolicySummarySidebar } from './SecurityGroupPolicySummarySidebar' import keyBy from 'lodash/keyBy' import type { TFunction } from 'i18next' @@ -73,6 +85,7 @@ export default function GovernanceOverview() { + @@ -396,6 +409,90 @@ function ClustersCard() { ) } +const policyConflictsPopoverBody = (t: TFunction) => ( + {t('policy.overview.conflicts.tooltip')} +) + +function PolicyConflictsCard() { + const { t } = useTranslation() + const policies = useAddRemediationPolicies() + const throttledPolicies = useMemo( + () => + policies + .filter((policy) => policy.throttled === true) + .sort((a, b) => compareStrings(a.metadata.name, b.metadata.name)), + [policies] + ) + const policyClusterViolationSummaryMap = usePolicyClusterViolationSummaryMap(throttledPolicies) + + if (throttledPolicies.length === 0) { + return null + } + + return ( +
+ + + + {t('Policy conflicts')} + + + + + + +
+ {throttledPolicies.map((policy) => { + const key = `${policy.metadata.namespace}.${policy.metadata.name}` + const clusterViolationSummary = policyClusterViolationSummaryMap[policy.metadata.uid ?? ''] + const policyDetailsPath = generatePath(NavigationPath.policyDetails, { + namespace: policy.metadata.namespace!, + name: policy.metadata.name!, + }) + const policyResultsPath = generatePath(NavigationPath.policyDetailsResults, { + namespace: policy.metadata.namespace ?? '', + name: policy.metadata.name ?? '', + }) + return ( + + + {policy.metadata.name} + + {clusterViolationSummary.compliant || + clusterViolationSummary.noncompliant || + clusterViolationSummary.pending || + clusterViolationSummary.unknown ? ( + + ) : ( + - + )} + + ) + })} +
+
+
+
+ ) +} + function renderClusterList( clusterList: { cluster: ManagedCluster; violations: ViolationSummary }[], onClick: (cluster: ManagedCluster, compliance: string) => void, diff --git a/frontend/src/routes/Governance/policies/Policies.tsx b/frontend/src/routes/Governance/policies/Policies.tsx index c9053d8db01..6801cfcf884 100644 --- a/frontend/src/routes/Governance/policies/Policies.tsx +++ b/frontend/src/routes/Governance/policies/Policies.tsx @@ -125,7 +125,7 @@ export default function PoliciesPage() { () => [ { header: t('Name'), - cell: handleNameCell, + cell: (item) => handleNameCell(item, t), sort: 'policy.metadata.name', search: 'policy.metadata.name', id: 'name', diff --git a/frontend/src/routes/Governance/policies/PolicyTableCell.tsx b/frontend/src/routes/Governance/policies/PolicyTableCell.tsx index fbeef4678d5..d62affb9769 100644 --- a/frontend/src/routes/Governance/policies/PolicyTableCell.tsx +++ b/frontend/src/routes/Governance/policies/PolicyTableCell.tsx @@ -8,13 +8,14 @@ import { PolicySetList } from '../common/util' import { PolicyActionDropdown } from '../components/PolicyActionDropdown' import { AcmButton } from '../../../ui-components/AcmButton' import { AutomationDetailsSidebar } from '../components/AutomationDetailsSidebar' -import { ButtonVariant } from '@patternfly/react-core' +import { ButtonVariant, Flex, FlexItem, Icon, Tooltip } from '@patternfly/react-core' +import { ClockIcon } from '@patternfly/react-icons' import type { TFunction } from 'i18next' import AcmTimestamp from '../../../lib/AcmTimestamp' import { AcmVisitedLink } from '../../../ui-components' -export function handleNameCell(item: PolicyTableItem) { - return ( +export function handleNameCell(item: PolicyTableItem, t: TFunction) { + const nameLink = ( ) + + if (item.policy.throttled !== true) { + return nameLink + } + + return ( + + {nameLink} + + + + + + + + + ) } export function handleStatusCell(item: PolicyTableItem, t: TFunction, returnExportString?: boolean) {