From 9a8903df5afdbbbfb155cf8ebd918f9055f38b32 Mon Sep 17 00:00:00 2001 From: Gabriel Bernal Date: Thu, 25 Jun 2026 14:00:25 +0200 Subject: [PATCH] feat: fetch lazily ai proposal for each row in the alert list Signed-off-by: Gabriel Bernal --- web/locales/en/plugin__monitoring-plugin.json | 7 +- web/src/components/CustomIcon.tsx | 34 +++++++ .../ai-proposals/alert-identifier.spec.ts | 99 +++++++++++++++++++ .../ai-proposals/alert-identifier.ts | 53 ++++++++++ web/src/components/ai-proposals/constants.ts | 17 ++++ .../ai-proposals/useProposalCheck.ts | 55 +++++++++++ .../alerting/AlertList/AlertTableRow.tsx | 49 +++++++-- web/src/components/alerting/AlertsPage.tsx | 30 ++++-- web/src/components/console/models/index.ts | 14 +++ web/src/components/data-test.ts | 1 + web/src/components/hooks/usePerspective.tsx | 17 ++++ web/src/components/kebab-dropdown.tsx | 9 +- 12 files changed, 363 insertions(+), 22 deletions(-) create mode 100644 web/src/components/CustomIcon.tsx create mode 100644 web/src/components/ai-proposals/alert-identifier.spec.ts create mode 100644 web/src/components/ai-proposals/alert-identifier.ts create mode 100644 web/src/components/ai-proposals/constants.ts create mode 100644 web/src/components/ai-proposals/useProposalCheck.ts diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json index 9e0e42f18..49b7a3101 100644 --- a/web/locales/en/plugin__monitoring-plugin.json +++ b/web/locales/en/plugin__monitoring-plugin.json @@ -16,7 +16,10 @@ "Severity": "Severity", "Namespace": "Namespace", "Source": "Source", + "Cluster": "Cluster", "Silence alert": "Silence alert", + "View AI Investigation": "View AI Investigation", + "Loading investigations...": "Loading investigations...", "User": "User", "Platform": "Platform", "Export as CSV": "Export as CSV", @@ -61,7 +64,6 @@ "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.": "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.", "Alert Name": "Alert Name", "Total": "Total", - "Cluster": "Cluster", "Filter by Cluster": "Filter by Cluster", "No alerts found": "No alerts found", "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.": "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.", @@ -247,7 +249,6 @@ "To get started add something to your dashboard": "To get started add something to your dashboard", "Edit": "Edit", "You don't have permission to edit this dashboard": "You don't have permission to edit this dashboard", - "No matching datasource found": "No matching datasource found", "No Dashboard Available in Selected Project": "No Dashboard Available in Selected Project", "To explore data, create a dashboard for this project": "To explore data, create a dashboard for this project", "No Perses Project Available": "No Perses Project Available", @@ -386,4 +387,4 @@ "Error loading latest targets data": "Error loading latest targets data", "Targets Table": "Targets Table", "No metrics targets found": "No metrics targets found" -} \ No newline at end of file +} diff --git a/web/src/components/CustomIcon.tsx b/web/src/components/CustomIcon.tsx new file mode 100644 index 000000000..47716daaa --- /dev/null +++ b/web/src/components/CustomIcon.tsx @@ -0,0 +1,34 @@ +// SVG path data sourced from @rhds/icons (CC-BY-4.0 licensed) +import type { FC } from 'react'; + +const icons = { + 'ai-experience': { + viewBox: '0 0 32 32', + d: 'M26.031 16.962a11.932 11.932 0 0 1-10.999-11c-.041-.52-.516-.961-1.038-.961s-.996.442-1.038.962A11.93 11.93 0 0 1 1.965 16.962c-.524.037-.97.514-.97 1.038 0 .521.442.997.962 1.038a11.933 11.933 0 0 1 11 11c.041.52.516.961 1.037.961.522 0 .997-.442 1.039-.962A11.931 11.931 0 0 1 26.03 19.038c.52-.042.962-.516.962-1.038 0-.521-.442-.997-.962-1.038Zm-12.037 8.803A13.888 13.888 0 0 0 6.228 18a13.898 13.898 0 0 0 7.767-7.766A13.888 13.888 0 0 0 21.76 18a13.899 13.899 0 0 0-7.767 7.766ZM30.502 7c0 .29-.209.536-.498.59a5.523 5.523 0 0 0-4.417 4.417c-.054.289-.3.498-.59.498s-.536-.21-.59-.498A5.52 5.52 0 0 0 19.99 7.59c-.289-.054-.498-.3-.498-.59s.21-.536.498-.59a5.52 5.52 0 0 0 4.417-4.417c.054-.289.301-.498.59-.498s.536.21.59.498a5.523 5.523 0 0 0 4.417 4.417c.289.054.498.3.498.59Z', + }, +} as const; + +export type CustomIconName = keyof typeof icons; + +type CustomIconProps = { + name: CustomIconName; + className?: string; +}; + +const CustomIcon: FC = ({ name, className }) => { + const icon = icons[name]; + return ( + + + + ); +}; + +export default CustomIcon; diff --git a/web/src/components/ai-proposals/alert-identifier.spec.ts b/web/src/components/ai-proposals/alert-identifier.spec.ts new file mode 100644 index 000000000..07d337542 --- /dev/null +++ b/web/src/components/ai-proposals/alert-identifier.spec.ts @@ -0,0 +1,99 @@ +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + ...jest.requireActual('@openshift-console/dynamic-plugin-sdk/lib/api/common-types'), +})); + +import { Alert, AlertStates, K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { + computeAlertFingerprint, + getAlertFingerprintPrefix, + matchesProposal, +} from './alert-identifier'; + +const makeAlert = (labels: Record): Alert => + ({ + labels: { alertname: 'TestAlert', ...labels }, + annotations: {}, + state: AlertStates.Firing, + rule: { id: '1', alerts: [], labels: {}, name: 'TestAlert', query: '', duration: 0 }, + }) as unknown as Alert; + +const makeProposal = (labels: Record): K8sResourceCommon => ({ + apiVersion: 'agentic.openshift.io/v1alpha1', + kind: 'Proposal', + metadata: { + name: 'test-proposal', + namespace: 'openshift-lightspeed', + labels, + }, +}); + +describe('computeAlertFingerprint', () => { + it('returns a 16-char hex string', () => { + const fp = computeAlertFingerprint({ alertname: 'TestAlert', severity: 'critical' }); + expect(fp).toHaveLength(16); + expect(fp).toMatch(/^[0-9a-f]{16}$/); + }); + + it('is deterministic for the same labels', () => { + const labels = { alertname: 'HighMemory', severity: 'warning', namespace: 'default' }; + expect(computeAlertFingerprint(labels)).toBe(computeAlertFingerprint(labels)); + }); + + it('produces the same result regardless of label insertion order', () => { + const fp1 = computeAlertFingerprint({ b: '2', a: '1' }); + const fp2 = computeAlertFingerprint({ a: '1', b: '2' }); + expect(fp1).toBe(fp2); + }); + + it('produces different fingerprints for different labels', () => { + const fp1 = computeAlertFingerprint({ alertname: 'AlertA' }); + const fp2 = computeAlertFingerprint({ alertname: 'AlertB' }); + expect(fp1).not.toBe(fp2); + }); + + it('returns empty label fingerprint for empty input', () => { + const fp = computeAlertFingerprint({}); + expect(fp).toHaveLength(16); + expect(fp).toMatch(/^[0-9a-f]{16}$/); + }); +}); + +describe('getAlertFingerprintPrefix', () => { + it('returns the first 8 characters of the fingerprint', () => { + const labels = { alertname: 'TestAlert' }; + const fp = computeAlertFingerprint(labels); + const prefix = getAlertFingerprintPrefix(labels); + expect(prefix).toHaveLength(8); + expect(fp.startsWith(prefix)).toBe(true); + }); +}); + +describe('matchesProposal', () => { + it('matches by fingerprint prefix', () => { + const alert = makeAlert({ severity: 'critical' }); + const fp8 = getAlertFingerprintPrefix(alert.labels); + const proposal = makeProposal({ + 'agentic.openshift.io/alert-fingerprint': fp8, + 'agentic.openshift.io/source': 'alertmanager', + }); + expect(matchesProposal(alert, proposal)).toBe(true); + }); + + it('does not match when fingerprint differs', () => { + const alert = makeAlert({ severity: 'critical' }); + const proposal = makeProposal({ + 'agentic.openshift.io/alert-fingerprint': 'deadbeef', + 'agentic.openshift.io/source': 'alertmanager', + }); + expect(matchesProposal(alert, proposal)).toBe(false); + }); + + it('does not match when proposal has no fingerprint label', () => { + const alert = makeAlert({ severity: 'warning' }); + const proposal = makeProposal({ + 'agentic.openshift.io/alert-name': 'testalert', + 'agentic.openshift.io/alert-severity': 'warning', + }); + expect(matchesProposal(alert, proposal)).toBe(false); + }); +}); diff --git a/web/src/components/ai-proposals/alert-identifier.ts b/web/src/components/ai-proposals/alert-identifier.ts new file mode 100644 index 000000000..7e5979358 --- /dev/null +++ b/web/src/components/ai-proposals/alert-identifier.ts @@ -0,0 +1,53 @@ +import { Alert, K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { + FINGERPRINT_PREFIX_LEN, + FNV_OFFSET_BASIS, + FNV_PRIME, + PROPOSAL_LABEL_FINGERPRINT, + SEPARATOR_BYTE, + UINT64_MASK, +} from './constants'; + +/** + * Computes the Prometheus-compatible FNV-1a 64-bit fingerprint of a label set. + * Replicates the algorithm from prometheus/common/model/signature.go: + * - Sort label names lexicographically + * - For each pair: hash(name + 0xFF + value + 0xFF) + * - Returns 16-char zero-padded hex string + */ +export const computeAlertFingerprint = (labels: Record): string => { + const names = Object.keys(labels).sort(); + + let hash = FNV_OFFSET_BASIS; + for (const name of names) { + const value = labels[name]; + const bytes = new TextEncoder().encode(name); + for (const b of bytes) { + hash ^= BigInt(b); + hash = (hash * FNV_PRIME) & UINT64_MASK; + } + hash ^= BigInt(SEPARATOR_BYTE); + hash = (hash * FNV_PRIME) & UINT64_MASK; + + const valueBytes = new TextEncoder().encode(value); + for (const b of valueBytes) { + hash ^= BigInt(b); + hash = (hash * FNV_PRIME) & UINT64_MASK; + } + hash ^= BigInt(SEPARATOR_BYTE); + hash = (hash * FNV_PRIME) & UINT64_MASK; + } + + return hash.toString(16).padStart(16, '0'); +}; + +export const getAlertFingerprintPrefix = (labels: Record): string => + computeAlertFingerprint(labels).slice(0, FINGERPRINT_PREFIX_LEN); + +export const matchesProposal = (alert: Alert, proposal: K8sResourceCommon): boolean => { + const proposalFp = proposal.metadata?.labels?.[PROPOSAL_LABEL_FINGERPRINT]; + if (!proposalFp) { + return false; + } + return proposalFp === getAlertFingerprintPrefix(alert.labels); +}; diff --git a/web/src/components/ai-proposals/constants.ts b/web/src/components/ai-proposals/constants.ts new file mode 100644 index 000000000..e8c4772f0 --- /dev/null +++ b/web/src/components/ai-proposals/constants.ts @@ -0,0 +1,17 @@ +// FNV-1a 64-bit hash constants (Prometheus alerts fingerprint algorithm) +export const SEPARATOR_BYTE = 0xff; +export const FNV_OFFSET_BASIS = 14695981039346656037n; +export const FNV_PRIME = 1099511628211n; +export const UINT64_MASK = (1n << 64n) - 1n; +export const FINGERPRINT_PREFIX_LEN = 8; + +// Proposal CR label keys +export const PROPOSAL_LABEL_FINGERPRINT = 'agentic.openshift.io/alert-fingerprint'; +export const PROPOSAL_LABEL_SOURCE = 'agentic.openshift.io/source'; +export const PROPOSAL_LABEL_ALERT_NAME = 'agentic.openshift.io/alert-name'; +export const PROPOSAL_LABEL_ALERT_SEVERITY = 'agentic.openshift.io/alert-severity'; + +// Proposal CR values +export const PROPOSAL_SOURCE_ALERTMANAGER = 'alertmanager'; +export const PROPOSAL_NAMESPACE = 'openshift-lightspeed'; +export const PROPOSAL_STALE_TIME = 30 * 1000; diff --git a/web/src/components/ai-proposals/useProposalCheck.ts b/web/src/components/ai-proposals/useProposalCheck.ts new file mode 100644 index 000000000..cfe0e1a69 --- /dev/null +++ b/web/src/components/ai-proposals/useProposalCheck.ts @@ -0,0 +1,55 @@ +import { Alert, K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { k8sListResourceItems } from '@openshift-console/dynamic-plugin-sdk/lib/utils/k8s'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useMemo, useState } from 'react'; +import { ProposalModel } from '../console/models'; +import { getAlertFingerprintPrefix, matchesProposal } from './alert-identifier'; +import { + PROPOSAL_LABEL_FINGERPRINT, + PROPOSAL_LABEL_SOURCE, + PROPOSAL_NAMESPACE, + PROPOSAL_SOURCE_ALERTMANAGER, + PROPOSAL_STALE_TIME, +} from './constants'; + +const buildQueryFn = (alertFingerprint: string) => () => + k8sListResourceItems({ + model: ProposalModel, + queryParams: { + ns: PROPOSAL_NAMESPACE, + labelSelector: { + matchLabels: { + [PROPOSAL_LABEL_FINGERPRINT]: alertFingerprint, + [PROPOSAL_LABEL_SOURCE]: PROPOSAL_SOURCE_ALERTMANAGER, + }, + }, + }, + }); + +export const useProposalCheck = (alert: Alert) => { + const alertFingerprint = useMemo(() => getAlertFingerprintPrefix(alert.labels), [alert.labels]); + const [shouldFetch, setShouldFetch] = useState(false); + + const { data, isFetching } = useQuery({ + queryKey: ['proposal-check', alertFingerprint], + queryFn: buildQueryFn(alertFingerprint), + enabled: shouldFetch, + staleTime: PROPOSAL_STALE_TIME, + retry: false, + }); + + const prefetch = useCallback(() => setShouldFetch(true), []); + + const proposals = useMemo( + () => (data ?? []).filter((p) => matchesProposal(alert, p)), + [data, alert], + ); + + return { + proposals, + hasProposal: proposals.length > 0, + isFetching, + prefetch, + alertFingerprint, + }; +}; diff --git a/web/src/components/alerting/AlertList/AlertTableRow.tsx b/web/src/components/alerting/AlertList/AlertTableRow.tsx index 2618946e2..ea92548cc 100644 --- a/web/src/components/alerting/AlertList/AlertTableRow.tsx +++ b/web/src/components/alerting/AlertList/AlertTableRow.tsx @@ -17,7 +17,7 @@ import { } from '../AlertUtils'; import { AlertSource } from '../../../components/types'; import { Td, Tr } from '@patternfly/react-table'; -import { DropdownItem, Flex, FlexItem } from '@patternfly/react-core'; +import { DropdownItem, Flex, FlexItem, Spinner } from '@patternfly/react-core'; import KebabDropdown from '../../../components/kebab-dropdown'; import type { FC } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,10 +26,13 @@ import { AlertResource, alertState } from '../../../components/utils'; import { getAlertUrl, getNewSilenceAlertUrl, + getProposalsUrl, usePerspective, } from '../../../components/hooks/usePerspective'; import { useMonitoringNamespace } from '../../hooks/useMonitoringNamespace'; import { DataTestIDs } from '../../data-test'; +import { useProposalCheck } from '../../ai-proposals/useProposalCheck'; +import CustomIcon from '../../CustomIcon'; const AlertTableRow: FC<{ alert: Alert }> = ({ alert }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); @@ -38,6 +41,8 @@ const AlertTableRow: FC<{ alert: Alert }> = ({ alert }) => { const { namespace } = useMonitoringNamespace(); const state = alertState(alert); + const { proposals, hasProposal, prefetch, alertFingerprint, isFetching } = + useProposalCheck(alert); const title: string = alert.annotations?.description || alert.annotations?.message; @@ -55,6 +60,36 @@ const AlertTableRow: FC<{ alert: Alert }> = ({ alert }) => { ); } + if (hasProposal) { + const proposalName = proposals.length === 1 ? proposals[0].metadata?.name : undefined; + const proposalUrl = getProposalsUrl( + perspective, + new URLSearchParams( + proposalName ? { name: proposalName } : { fingerprint: alertFingerprint }, + ), + ); + dropdownItems.push( + } + onClick={() => navigate(proposalUrl)} + data-test={DataTestIDs.ViewAIInvestigationDropdownItem} + > + {t('View AI Investigation')} + , + ); + } else if (isFetching) { + dropdownItems.push( + } + isDisabled + > + {t('Loading investigations...')} + , + ); + } + const getDropdownItemsWithExtension = (actions: Action[]) => { const extensionDropdownItems = []; actions.forEach((action) => { @@ -121,13 +156,11 @@ const AlertTableRow: FC<{ alert: Alert }> = ({ alert }) => { {({ actions, loaded }) => { - if (loaded && actions.length > 0) { - return ; - } else { - return dropdownItems?.length > 0 ? ( - - ) : null; - } + const items = + loaded && actions.length > 0 ? getDropdownItemsWithExtension(actions) : dropdownItems; + return items?.length > 0 ? ( + + ) : null; }} diff --git a/web/src/components/alerting/AlertsPage.tsx b/web/src/components/alerting/AlertsPage.tsx index 6783aaa1c..72877d1b0 100644 --- a/web/src/components/alerting/AlertsPage.tsx +++ b/web/src/components/alerting/AlertsPage.tsx @@ -1,4 +1,5 @@ import { AlertSeverity, AlertStates, DocumentTitle } from '@openshift-console/dynamic-plugin-sdk'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PageSection, PaginationVariant } from '@patternfly/react-core'; import DataView from '@patternfly/react-data-view/dist/dynamic/DataView'; import DataViewTableHead from '@patternfly/react-data-view/dist/dynamic/DataViewTableHead'; @@ -322,6 +323,15 @@ const AlertsPage_: FC = () => { }; const AlertsPageWithFallback = withFallback(AlertsPage_); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + const sortAggregatedAlerts = ( data: AggregatedAlert[], sortBy: string | undefined, @@ -344,18 +354,22 @@ const sortAggregatedAlerts = ( export const MpCmoAlertsPage = () => { return ( - - - + + + + + ); }; export const McpAcmAlertsPage = () => { return ( - - - + + + + + ); }; diff --git a/web/src/components/console/models/index.ts b/web/src/components/console/models/index.ts index b2b532f69..294511c33 100644 --- a/web/src/components/console/models/index.ts +++ b/web/src/components/console/models/index.ts @@ -91,3 +91,17 @@ export const StatefulSetModel = { namespaced: true, kind: 'StatefulSet', }; + +export const ProposalModel: K8sModel = { + kind: 'Proposal', + label: 'Proposal', + labelKey: 'Proposal', + labelPlural: 'Proposals', + labelPluralKey: 'Proposals', + apiGroup: 'agentic.openshift.io', + apiVersion: 'v1alpha1', + abbr: 'PP', + namespaced: true, + crd: true, + plural: 'proposals', +}; diff --git a/web/src/components/data-test.ts b/web/src/components/data-test.ts index eea9da931..09b0f787f 100644 --- a/web/src/components/data-test.ts +++ b/web/src/components/data-test.ts @@ -65,6 +65,7 @@ export const DataTestIDs = { SeverityBadgeHeader: 'severity-badge-header', SeverityBadge: 'severity-badge', SilenceAlertDropdownItem: 'silence-alert-dropdown-item', + ViewAIInvestigationDropdownItem: 'view-ai-investigation-dropdown-item', SilenceButton: 'silence-button', SilenceEditDropdownItem: 'silence-edit-dropdown-item', SilenceExpireDropdownItem: 'silence-expire-dropdown-item', diff --git a/web/src/components/hooks/usePerspective.tsx b/web/src/components/hooks/usePerspective.tsx index f3eb45d8a..47fe14dd6 100644 --- a/web/src/components/hooks/usePerspective.tsx +++ b/web/src/components/hooks/usePerspective.tsx @@ -318,3 +318,20 @@ export const getDashboardsListUrl = (perspective: Perspective) => { return ''; } }; + +export const getProposalsUrl = (perspective: Perspective, params?: URLSearchParams): string => { + const qs = params ? params.toString() : ''; + const queryParams = qs ? `?${qs}` : ''; + + switch (perspective) { + case 'acm': + return `/multicloud/monitoring/v2/ai/proposals${queryParams}`; + case 'dev': + return `/dev-monitoring/v2/ai/proposals${queryParams}`; + case 'virtualization-perspective': + return `/virt-monitoring/v2/ai/proposals${queryParams}`; + case 'admin': + default: + return `/monitoring/v2/ai/proposals${queryParams}`; + } +}; diff --git a/web/src/components/kebab-dropdown.tsx b/web/src/components/kebab-dropdown.tsx index 231bf2341..6212e6fbb 100644 --- a/web/src/components/kebab-dropdown.tsx +++ b/web/src/components/kebab-dropdown.tsx @@ -1,12 +1,14 @@ -import type { FC, Ref } from 'react'; +import type { FC, MouseEventHandler, ReactNode, Ref } from 'react'; import { Dropdown, DropdownList, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; import { EllipsisVIcon } from '@patternfly/react-icons'; import { useBoolean } from './hooks/useBoolean'; import { DataTestIDs } from './data-test'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const KebabDropdown: FC<{ dropdownItems: any[] }> = ({ dropdownItems }) => { +const KebabDropdown: FC<{ + dropdownItems: ReactNode; + onMouseEnter?: MouseEventHandler; +}> = ({ dropdownItems, onMouseEnter }) => { const [isOpen, setIsOpen, setOpen, setClosed] = useBoolean(false); return ( @@ -23,6 +25,7 @@ const KebabDropdown: FC<{ dropdownItems: any[] }> = ({ dropdownItems }) => { data-test={DataTestIDs.KebabDropdownButton} variant="plain" onClick={setIsOpen} + onMouseEnter={onMouseEnter} isExpanded={isOpen} >