From a40f36327671603ccd6b37d24c6a7038bee388ce Mon Sep 17 00:00:00 2001 From: Martin Stone Date: Wed, 12 Jun 2024 12:54:38 -0400 Subject: [PATCH 01/26] local checkpoint 1 --- .../ColumnsModal/ColumnsModalComponent.jsx | 36 +++++----- src/config/column-generator.jsx | 66 +++++++++++++++++++ src/redux/log_entries/sagas.js | 2 +- src/util/helpers.js | 29 ++++++++ 4 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/components/ColumnsModal/ColumnsModalComponent.jsx b/src/components/ColumnsModal/ColumnsModalComponent.jsx index b5af8d5e..c235508e 100644 --- a/src/components/ColumnsModal/ColumnsModalComponent.jsx +++ b/src/components/ColumnsModal/ColumnsModalComponent.jsx @@ -41,7 +41,7 @@ import { import { defaultColumns, customAlertColumns, - columnsForSavedColumns, + // columnsForSavedColumns, } from 'src/config/column-generator'; import { @@ -106,19 +106,25 @@ const TableColumnsModalComponent = () => { const allAvailableColumns = useMemo(getAllAvailableColumns, [alertCustomDetailFields]); - const getSelectedColumns = () => columnsForSavedColumns(incidentTableColumns).map((column) => { - // Recreate original value used from react-select in order to populate dual list - const value = columnValue(column); - return { - id: column.id, - Header: column.Header, - columnType: column.columnType, - accessor: column.accessor, - accessorPath: column.accessorPath, - label: column.i18n ? column.i18n : column.Header, - value, - }; - }); + // const getSelectedColumns = () => columnsForSavedColumns(incidentTableColumns).map((column) => { + // // Recreate original value used from react-select in order to populate dual list + // const value = columnValue(column); + // return { + // id: column.id, + // Header: column.Header, + // columnType: column.columnType, + // accessor: column.accessor, + // accessorPath: column.accessorPath, + // label: column.i18n ? column.i18n : column.Header, + // value, + // }; + // }); + const getSelectedColumns = () => incidentTableColumns.map((column) => ( + { + ...column, + value: columnValue(column), + } + )); const [selectedColumns, setSelectedColumns] = useState(getSelectedColumns()); const getUnselectedColumns = () => { @@ -143,7 +149,7 @@ const TableColumnsModalComponent = () => { accessorPath, aggregator, value, - label: value, + // label: value, columnType: 'alert', }; const newAlertCustomDetailFields = [...alertCustomDetailFields, newColumn]; diff --git a/src/config/column-generator.jsx b/src/config/column-generator.jsx index 41bda4a0..3e306c8e 100644 --- a/src/config/column-generator.jsx +++ b/src/config/column-generator.jsx @@ -33,6 +33,10 @@ import { } from 'react-redux'; import i18next from 'src/i18n'; +import { + anythingToString, +} from 'src/util/helpers'; + import { HIGH, LOW, } from 'src/util/incidents'; @@ -775,6 +779,65 @@ export const defaultAlertsColumns = () => [ }), ]; +export const computedColumnForSavedColumn = (savedColumn) => { + console.log('savedColumn', savedColumn); + const { + Header: header, + accessorPath, + expressionType, + expression, + width, + } = savedColumn; + if (!(header && accessorPath)) { + console.log('returning null 1'); + return null; + } + const accessor = (incident) => { + console.log({ accessorPath, expressionType, expression }); + try { + const valuesAtPath = JSONPath({ + path: accessorPath, + json: incident, + }); + if (!valuesAtPath) { + console.log('returning null for empty at path'); + return null; + } + if (expressionType === 'regex') { + const stringValuesAtPath = valuesAtPath.map((v) => anythingToString(v)); + const joinedValuesAtPath = stringValuesAtPath.join('\n'); + const regex = new RegExp(expression, 'gm'); + const matches = Array.from(joinedValuesAtPath.matchAll(regex), (match) => match[1]); + console.log('matches', matches); + return matches.join(', ') || null; + } + if (expressionType === 'regex-single') { + const stringValuesAtPath = valuesAtPath.map((v) => anythingToString(v)); + const joinedValuesAtPath = stringValuesAtPath.join('\n'); + const regex = new RegExp(expression, 'm'); + const match = joinedValuesAtPath.match(regex); + console.log('match', match); + return match ? match[1] : null; + } + console.log('returning valuesAtPath[0]'); + return valuesAtPath[0]; + } catch (e) { + console.log('returning null for exception', e); + return null; + } + }; + const column = incidentColumn({ + id: `${header}-accessorPath`, + header, + columnType: 'computed', + accessor, + accessorPath, + minWidth: width || 100, + }); + console.log('column', column); + return column; +}; + export const customAlertColumnForSavedColumn = (savedColumn) => { const { Header: header, accessorPath, aggregator, width, @@ -847,6 +910,9 @@ export const columnsForSavedColumns = (savedColumns) => { if (column.columnType === 'alert') { return customAlertColumnForSavedColumn(column); } + if (column.columnType === 'computed') { + return computedColumnForSavedColumn(column); + } return null; }) .filter((c) => !!c); diff --git a/src/redux/log_entries/sagas.js b/src/redux/log_entries/sagas.js index 089d9f12..eb69fc62 100644 --- a/src/redux/log_entries/sagas.js +++ b/src/redux/log_entries/sagas.js @@ -64,7 +64,7 @@ export function* getLogEntries(action) { const params = { since: since.toISOString().replace(/\.[\d]{3}/, ''), - 'include[]': ['incidents', 'linked_incidents', 'external_references', 'channels'], + 'include[]': ['incidents', 'linked_incidents', 'external_references', 'channels', 'first_trigger_log_entries'], }; let logEntries; try { diff --git a/src/util/helpers.js b/src/util/helpers.js index 0fd07b59..72b964d3 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -106,3 +106,32 @@ export const chunkArray = (array, size) => { } return chunkedArr; }; + +export const anythingToString = (value) => { + // Check for null and undefined + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + // Handle different types + switch (typeof value) { + case 'boolean': + case 'number': + case 'bigint': + case 'symbol': + case 'string': + return value.toString(); + + case 'function': + return value.toString(); // Functions are turned into their source code + + case 'object': + try { + return JSON.stringify(value); // Convert objects and arrays to JSON + } catch (error) { + return value.toString(); // Fallback for circular references or non-serializable objects + } + + default: + return String(value); // Fallback for any other type + } +}; From c176204d507203da28dbc87abbdd7c13ee5710ed Mon Sep 17 00:00:00 2001 From: Gavin Reynolds Date: Sun, 30 Jun 2024 15:23:58 +0100 Subject: [PATCH 02/26] Add additional test for existing column generator functionality Signed-off-by: Gavin Reynolds --- .../IncidentTable/IncidentTableComponent.test.js | 16 ++++++++++++++++ src/mocks/incidents.test.js | 2 +- src/redux/incidents/sagas.test.js | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/IncidentTable/IncidentTableComponent.test.js b/src/components/IncidentTable/IncidentTableComponent.test.js index e66c8e65..e712a2ee 100644 --- a/src/components/IncidentTable/IncidentTableComponent.test.js +++ b/src/components/IncidentTable/IncidentTableComponent.test.js @@ -75,6 +75,13 @@ describe('IncidentTableComponent', () => { width: 100, columnType: 'alert', }, + { + Header: 'some obscure field', + accessorPath: "details['some obscure field']", + aggregator: null, + width: 100, + columnType: 'alert', + }, ], }, incidentActions: { @@ -126,4 +133,13 @@ describe('IncidentTableComponent', () => { // jsonValue should include a key with value 'value1' expect(JSON.stringify(jsonValue)).toContain('value1'); }); + + it('should render cell with UUID value for custom detail field', () => { + const incidentNumber = 1; + const customDetailField = 'some obscure field'; + const uuid = screen.getAllByIncidentHeader(customDetailField)[incidentNumber].textContent; + + // uuid should include a valid UUID + expect(validator.isUUID(uuid)).toBeTruthy(); + }); }); diff --git a/src/mocks/incidents.test.js b/src/mocks/incidents.test.js index 3182c045..14e3ac08 100644 --- a/src/mocks/incidents.test.js +++ b/src/mocks/incidents.test.js @@ -24,7 +24,7 @@ const generateMockAlert = () => { const link = faker.internet.url(); const customDetails = { quote, - 'some obsecure field': uuid, + 'some obscure field': uuid, link, object_details: { key1: 'value1', diff --git a/src/redux/incidents/sagas.test.js b/src/redux/incidents/sagas.test.js index b0202b57..77a61adb 100644 --- a/src/redux/incidents/sagas.test.js +++ b/src/redux/incidents/sagas.test.js @@ -153,7 +153,7 @@ describe('Sagas: Incidents', () => { it('filterIncidents: Search by Alert Custom Detail Field', () => { const mockIncident = mockIncidents[0]; - const customField = 'some obsecure field'; + const customField = 'some obscure field'; const customFieldValue = mockIncident.alerts[0].body.details[customField]; const expectedIncidentResult = [mockIncident]; return expectSaga(filterIncidents) From 6eb1d6fe5ac9c72ba7a4146221689575a44cd77b Mon Sep 17 00:00:00 2001 From: Gavin Reynolds Date: Sun, 30 Jun 2024 18:16:24 +0100 Subject: [PATCH 03/26] Add regex-single and regex test for hostname in incident details Signed-off-by: Gavin Reynolds --- .../IncidentTableComponent.test.js | 36 +++++++++++++++++++ src/mocks/incidents.test.js | 24 +++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/components/IncidentTable/IncidentTableComponent.test.js b/src/components/IncidentTable/IncidentTableComponent.test.js index e712a2ee..94923ca0 100644 --- a/src/components/IncidentTable/IncidentTableComponent.test.js +++ b/src/components/IncidentTable/IncidentTableComponent.test.js @@ -82,6 +82,24 @@ describe('IncidentTableComponent', () => { width: 100, columnType: 'alert', }, + { + Header: 'regex-single in incident body', + accessorPath: 'first_trigger_log_entry.channel.details', + aggregator: null, + width: 100, + columnType: 'computed', + expressionType: 'regex-single', + expression: '(.*.example.com)', + }, + { + Header: 'regex in incident body', + accessorPath: 'first_trigger_log_entry.channel.details', + aggregator: null, + width: 100, + columnType: 'computed', + expressionType: 'regex', + expression: '(.*.example.com)', + }, ], }, incidentActions: { @@ -142,4 +160,22 @@ describe('IncidentTableComponent', () => { // uuid should include a valid UUID expect(validator.isUUID(uuid)).toBeTruthy(); }); + + it('should render computed cell with regex-single expression value for hostname in incident details field', () => { + const incidentNumber = 1; + const customDetailField = 'regex-single in incident body'; + const host = screen.getAllByIncidentHeader(customDetailField)[incidentNumber].textContent; + + // host should be the hostname regex matched out of the incident details + expect(host).toEqual('test1234.example.com'); + }); + + it('should render computed cell with regex expression value for hostname in incident details field', () => { + const incidentNumber = 1; + const customDetailField = 'regex in incident body'; + const hosts = screen.getAllByIncidentHeader(customDetailField)[incidentNumber].textContent; + + // hosts should be the hostnames regex matched out of the incident details + expect(hosts).toEqual('test1234.example.com, test5678.example.com'); + }); }); diff --git a/src/mocks/incidents.test.js b/src/mocks/incidents.test.js index 14e3ac08..985f4e0c 100644 --- a/src/mocks/incidents.test.js +++ b/src/mocks/incidents.test.js @@ -109,6 +109,7 @@ export const generateMockIncident = () => { const status = INCIDENT_STATES[Math.floor(Math.random() * INCIDENT_STATES.length)]; const incidentKey = faker.string.alphanumeric(32); const incidentId = faker.string.alphanumeric(14); + const logEntryId = faker.string.alphanumeric(14); const escalationPolicyId = faker.string.alphanumeric(7); const serviceId = faker.string.alphanumeric(7); const createdAt = faker.date @@ -133,6 +134,29 @@ export const generateMockIncident = () => { }, alerts: generateMockAlerts(5), notes: generateMockNotes(5), + first_trigger_log_entry: { + // FIXME: This is only for a web_trigger, would not be present for alert triggered incident + id: logEntryId, + type: 'trigger_log_entry', + summary: 'Triggered through the website.', + created_at: createdAt, + agent: {}, + channel: { + type: 'web_trigger', + summary: title, + subject: title, + details: 'Here is the description;\n\ntest1234.example.com\n\ntest5678.example.com', + }, + service: { id: serviceId }, + incident: { + id: incidentId, + type: 'incident_reference', + summary: title, + }, + teams: [], + contexts: [], + event_details: { description: title }, + }, }; }; From 70c6b4e4246380a9918cc245849809a40915d8cf Mon Sep 17 00:00:00 2001 From: Gavin Reynolds Date: Sun, 30 Jun 2024 21:24:49 +0100 Subject: [PATCH 04/26] Add computed null condition tests Signed-off-by: Gavin Reynolds --- .../IncidentTableComponent.test.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/components/IncidentTable/IncidentTableComponent.test.js b/src/components/IncidentTable/IncidentTableComponent.test.js index 94923ca0..a28307c1 100644 --- a/src/components/IncidentTable/IncidentTableComponent.test.js +++ b/src/components/IncidentTable/IncidentTableComponent.test.js @@ -100,6 +100,24 @@ describe('IncidentTableComponent', () => { expressionType: 'regex', expression: '(.*.example.com)', }, + { + Header: 'no match regex in incident body', + accessorPath: 'first_trigger_log_entry.channel.details', + aggregator: null, + width: 100, + columnType: 'computed', + expressionType: 'regex', + expression: '(.*foobar)', + }, + { + Header: 'no match regex in non-existant path', + accessorPath: 'first_trigger_log_entry.channel.foobar', + aggregator: null, + width: 100, + columnType: 'computed', + expressionType: 'regex', + expression: '(.*)', + }, ], }, incidentActions: { @@ -178,4 +196,22 @@ describe('IncidentTableComponent', () => { // hosts should be the hostnames regex matched out of the incident details expect(hosts).toEqual('test1234.example.com, test5678.example.com'); }); + + it('should render computed cell with no regex match in incident details field', () => { + const incidentNumber = 1; + const customDetailField = 'no match regex in incident body'; + const match = screen.getAllByIncidentHeader(customDetailField)[incidentNumber].textContent; + + // hosts should be the hostnames regex matched out of the incident details + expect(match).toEqual('--'); + }); + + it('should render computed cell with non-existant path', () => { + const incidentNumber = 1; + const customDetailField = 'no match regex in non-existant path'; + const match = screen.getAllByIncidentHeader(customDetailField)[incidentNumber].textContent; + + // hosts should be the hostnames regex matched out of the incident details + expect(match).toEqual('--'); + }); }); From 37a1dbb1ea367d8c5fb65b2e190fa495453f7776 Mon Sep 17 00:00:00 2001 From: Gavin Reynolds Date: Sun, 30 Jun 2024 21:26:21 +0100 Subject: [PATCH 05/26] Remove console.log statements from column-generator Signed-off-by: Gavin Reynolds --- src/config/column-generator.jsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/config/column-generator.jsx b/src/config/column-generator.jsx index 3e306c8e..704107ac 100644 --- a/src/config/column-generator.jsx +++ b/src/config/column-generator.jsx @@ -780,7 +780,6 @@ export const defaultAlertsColumns = () => [ ]; export const computedColumnForSavedColumn = (savedColumn) => { - console.log('savedColumn', savedColumn); const { Header: header, accessorPath, @@ -789,18 +788,15 @@ export const computedColumnForSavedColumn = (savedColumn) => { width, } = savedColumn; if (!(header && accessorPath)) { - console.log('returning null 1'); return null; } const accessor = (incident) => { - console.log({ accessorPath, expressionType, expression }); try { const valuesAtPath = JSONPath({ path: accessorPath, json: incident, }); if (!valuesAtPath) { - console.log('returning null for empty at path'); return null; } if (expressionType === 'regex') { @@ -808,7 +804,6 @@ export const computedColumnForSavedColumn = (savedColumn) => { const joinedValuesAtPath = stringValuesAtPath.join('\n'); const regex = new RegExp(expression, 'gm'); const matches = Array.from(joinedValuesAtPath.matchAll(regex), (match) => match[1]); - console.log('matches', matches); return matches.join(', ') || null; } if (expressionType === 'regex-single') { @@ -816,13 +811,10 @@ export const computedColumnForSavedColumn = (savedColumn) => { const joinedValuesAtPath = stringValuesAtPath.join('\n'); const regex = new RegExp(expression, 'm'); const match = joinedValuesAtPath.match(regex); - console.log('match', match); return match ? match[1] : null; } - console.log('returning valuesAtPath[0]'); return valuesAtPath[0]; } catch (e) { - console.log('returning null for exception', e); return null; } }; @@ -834,7 +826,6 @@ export const computedColumnForSavedColumn = (savedColumn) => { accessorPath, minWidth: width || 100, }); - console.log('column', column); return column; }; From 708da0a65811105d96dad36c43b21d97c482944a Mon Sep 17 00:00:00 2001 From: Gavin Reynolds Date: Sun, 30 Jun 2024 22:19:45 +0100 Subject: [PATCH 06/26] computed columns UI WIP Signed-off-by: Gavin Reynolds --- .../ColumnsModal/ColumnsModalComponent.jsx | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/components/ColumnsModal/ColumnsModalComponent.jsx b/src/components/ColumnsModal/ColumnsModalComponent.jsx index c235508e..7e5f7c4c 100644 --- a/src/components/ColumnsModal/ColumnsModalComponent.jsx +++ b/src/components/ColumnsModal/ColumnsModalComponent.jsx @@ -34,6 +34,7 @@ import { Input, Text, useToast, + Select, } from '@chakra-ui/react'; import { AddIcon, @@ -123,6 +124,7 @@ const TableColumnsModalComponent = () => { { ...column, value: columnValue(column), + label: column.i18n ? column.i18n : column.Header, } )); const [selectedColumns, setSelectedColumns] = useState(getSelectedColumns()); @@ -156,6 +158,22 @@ const TableColumnsModalComponent = () => { setAlertCustomDetailColumns(newAlertCustomDetailFields); }; + const addCustomComputedColumn = (value) => { + const [Header, accessorPath, aggregator, expression] = value.split(':'); + const newColumn = { + Header, + accessorPath, + aggregator, + value, + // expressionType, + expression, + // label: value, + columnType: 'alert', + }; + const newAlertCustomDetailFields = [...alertCustomDetailFields, newColumn]; + setAlertCustomDetailColumns(newAlertCustomDetailFields); + }; + const removeCustomAlertColumn = (column) => { unselectColumn(column); const newAlertCustomDetailFields = alertCustomDetailFields.filter( @@ -164,10 +182,14 @@ const TableColumnsModalComponent = () => { setAlertCustomDetailColumns(newAlertCustomDetailFields); }; + const columnTypeInputRef = useRef(null); const headerInputRef = useRef(null); const accessorPathInputRef = useRef(null); + const regexInputRef = useRef(null); const addButtonRef = useRef(null); + const [columnType, setColumnType] = useState(null); + const [inputIsValid, setInputIsValid] = useState(false); const validateInput = () => { let valid = true; @@ -206,7 +228,7 @@ const TableColumnsModalComponent = () => { const [, drop] = useDrop(() => ({ accept: 'DraggableColumnsModalItem' })); return ( - + {t('Incident Table')} @@ -270,6 +292,21 @@ const TableColumnsModalComponent = () => { ))} + { ref={accessorPathInputRef} onChange={validateInput} m={1} - w="60%" + w="50%" placeholder={t('JSON Path')} size="sm" /> +