Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions web/locales/en/plugin__monitoring-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
34 changes: 34 additions & 0 deletions web/src/components/CustomIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<CustomIconProps> = ({ name, className }) => {
const icon = icons[name];
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={icon.viewBox}
fill="currentColor"
width="1em"
height="1em"
className={className}
>
<path d={icon.d} />
</svg>
);
};

export default CustomIcon;
99 changes: 99 additions & 0 deletions web/src/components/ai-proposals/alert-identifier.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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<string, string>): 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);
});
});
53 changes: 53 additions & 0 deletions web/src/components/ai-proposals/alert-identifier.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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, string>): 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);
};
17 changes: 17 additions & 0 deletions web/src/components/ai-proposals/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
55 changes: 55 additions & 0 deletions web/src/components/ai-proposals/useProposalCheck.ts
Original file line number Diff line number Diff line change
@@ -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<K8sResourceCommon>({
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,
};
};
49 changes: 41 additions & 8 deletions web/src/components/alerting/AlertList/AlertTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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;

Expand All @@ -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(
<DropdownItem
key="view-ai-investigation"
icon={<CustomIcon name="ai-experience" />}
onClick={() => navigate(proposalUrl)}
data-test={DataTestIDs.ViewAIInvestigationDropdownItem}
>
{t('View AI Investigation')}
</DropdownItem>,
);
Comment thread
jgbernalp marked this conversation as resolved.
} else if (isFetching) {
dropdownItems.push(
<DropdownItem
key="loading-ai-investigation"
icon={<CustomIcon name="ai-experience" />}
isDisabled
>
<Spinner size="sm" /> {t('Loading investigations...')}
Comment thread
jgbernalp marked this conversation as resolved.
</DropdownItem>,
);
}

const getDropdownItemsWithExtension = (actions: Action[]) => {
const extensionDropdownItems = [];
actions.forEach((action) => {
Expand Down Expand Up @@ -121,13 +156,11 @@ const AlertTableRow: FC<{ alert: Alert }> = ({ alert }) => {
<Td title={title}>
<ActionServiceProvider context={{ 'monitoring-alert-list-item': { alert: alert } }}>
{({ actions, loaded }) => {
if (loaded && actions.length > 0) {
return <KebabDropdown dropdownItems={getDropdownItemsWithExtension(actions)} />;
} else {
return dropdownItems?.length > 0 ? (
<KebabDropdown dropdownItems={dropdownItems} />
) : null;
}
const items =
loaded && actions.length > 0 ? getDropdownItemsWithExtension(actions) : dropdownItems;
return items?.length > 0 ? (
<KebabDropdown dropdownItems={items} onMouseEnter={prefetch} />
) : null;
}}
</ActionServiceProvider>
</Td>
Expand Down
Loading