From bfede7eca7d82bffba11d3005f998c3d85284077 Mon Sep 17 00:00:00 2001 From: Szymon Oleksy Date: Wed, 19 Jun 2024 11:16:21 +0200 Subject: [PATCH 01/25] feat: part 1 --- .../data_ingestion_tab_definition.json | 16 +++++----- ui/src/pages/Dashboard/DataIngestion.tsx | 30 +++++++++++++++++-- ui/src/pages/Dashboard/EventHandlers.ts | 26 ++++++++++++++++ 3 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 ui/src/pages/Dashboard/EventHandlers.ts diff --git a/splunk_add_on_ucc_framework/templates/data_ingestion_tab_definition.json b/splunk_add_on_ucc_framework/templates/data_ingestion_tab_definition.json index 219c5d16b..ce9ca9062 100644 --- a/splunk_add_on_ucc_framework/templates/data_ingestion_tab_definition.json +++ b/splunk_add_on_ucc_framework/templates/data_ingestion_tab_definition.json @@ -55,9 +55,7 @@ "options": { "xAxisVisibility": "hide", "xAxisTitleText": "Time", - "seriesColors": [ - "#A870EF" - ], + "seriesColors": ["#A870EF"], "yAxisTitleText": "Number of events" }, "title": "Number of events", @@ -104,7 +102,12 @@ } }, "count": 10 - } + }, + "eventHandlers": [ + { + "type": "table.click.handler" + } + ] } }, "dataSources": { @@ -206,10 +209,7 @@ }, "layout": { "type": "grid", - "globalInputs": [ - "data_ingestion_input", - "data_ingestion_table_input" - ], + "globalInputs": ["data_ingestion_input", "data_ingestion_table_input"], "structure": [ { "item": "data_ingestion_label_viz", diff --git a/ui/src/pages/Dashboard/DataIngestion.tsx b/ui/src/pages/Dashboard/DataIngestion.tsx index d27067f26..2dce1df92 100644 --- a/ui/src/pages/Dashboard/DataIngestion.tsx +++ b/ui/src/pages/Dashboard/DataIngestion.tsx @@ -5,14 +5,17 @@ import EnterpriseViewOnlyPreset from '@splunk/dashboard-presets/EnterpriseViewOn import Search from '@splunk/react-ui/Search'; import Message from '@splunk/react-ui/Message'; import type { DashboardCoreApi } from '@splunk/dashboard-types'; - +import Card from '@splunk/react-ui/Card'; +import Button from '@splunk/react-ui/Button'; import { debounce } from 'lodash'; + import { createNewQueryBasedOnSearchAndHideTraffic, getActionButtons, makeVisualAdjustmentsOnDataIngestionPage, addDescriptionToExpandedViewByOptions, } from './utils'; +import TableClickHandler from './EventHandlers'; const VIEW_BY_INFO_MAP: Record = { Input: 'Volume metrics are not available when the Input view is selected.', @@ -26,7 +29,8 @@ export const DataIngestionDashboard = ({ dashboardDefinition: Record; }) => { const dashboardCoreApi = React.useRef(); - + const [showCard, setShowCard] = useState(false); + const [selectedInput, setSelectedInput] = useState(''); const [searchInput, setSearchInput] = useState(''); const [viewByInput, setViewByInput] = useState(''); const [toggleNoTraffic, setToggleNoTraffic] = useState(false); @@ -112,7 +116,12 @@ export const DataIngestionDashboard = ({ return ( <> <> @@ -143,6 +152,21 @@ export const DataIngestionDashboard = ({ Hide items with no traffic */} + {showCard ? ( + + } + /> + + {selectedInput} + + + + + + ) : null}
{infoMessage ? ( diff --git a/ui/src/pages/Dashboard/EventHandlers.ts b/ui/src/pages/Dashboard/EventHandlers.ts new file mode 100644 index 000000000..36c009e9a --- /dev/null +++ b/ui/src/pages/Dashboard/EventHandlers.ts @@ -0,0 +1,26 @@ +/* eslint-disable class-methods-use-this */ +class TableClickHandler { + options?: { field?: string }; + + constructor(options?: Record | undefined) { + this.options = options; + } + + canHandle(event: { type: string; payload?: Record }) { + // eslint-disable-next-line no-console + console.log('clicked', { + cellIndex: event?.payload?.cellIndex, + rowIndex: event?.payload?.rowIndex, + value: event?.payload?.value, + fieldValue: event?.payload?.fieldValue, + }); + return event.type === 'cell.click'; + } + + handle() { + // method never used + return new Promise((r) => r(1)); + } +} + +export default TableClickHandler; From 36a829a5e5d5c13756a28e0ff291103ab06463e5 Mon Sep 17 00:00:00 2001 From: sgoral Date: Thu, 20 Jun 2024 13:27:20 +0200 Subject: [PATCH 02/25] chore: add definition json --- .../spike_side_panel_definition.json | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json diff --git a/splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json b/splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json new file mode 100644 index 000000000..6a7ad6957 --- /dev/null +++ b/splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json @@ -0,0 +1,117 @@ +{ + "visualizations": { + "spike_side_panel_data_volume_viz": { + "type": "splunk.line", + "options": { + "xAxisVisibility": "hide", + "seriesColors": [ + "#A870EF" + ], + "yAxisTitleText": "Volume (bytes)", + "xAxisTitleText": "Time" + }, + "title": "Data volume", + "dataSources": { + "primary": "spike_side_panel_data_volume_ds" + } + }, + "spike_side_panel_events_count_viz": { + "type": "splunk.line", + "options": { + "xAxisVisibility": "hide", + "xAxisTitleText": "Time", + "seriesColors": [ + "#A870EF" + ], + "yAxisTitleText": "Number of events" + }, + "title": "Number of events", + "dataSources": { + "primary": "ds_search_1" + } + } + }, + "dataSources": { + "spike_side_panel_data_volume_ds": { + "type": "ds.search", + "options": { + "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", + "queryParameters": { + "earliest": "$spike_side_panel_time.earliest$", + "latest": "$spike_side_panel_time.latest$" + } + } + }, + "ds_search_1": { + "type": "ds.search", + "options": { + "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", + "queryParameters": { + "earliest": "$spike_side_panel_time.earliest$", + "latest": "$spike_side_panel_time.latest$" + } + }, + "name": "Security Score vs Spend" + } + }, + "defaults": {}, + "inputs": { + "spike_side_panel_input": { + "options": { + "defaultValue": "-24h,now", + "token": "spike_side_panel_time" + }, + "title": "Time", + "type": "input.timerange" + }, + "input1": { + "type": "input.dropdown", + "options": { + "items": [ + { + "label": "Source type", + "value": "Source type" + }, + { + "label": "Source", + "value": "Source" + }, + { + "label": "Host", + "value": "Host" + } + ], + "defaultValue": "Source type", + "token": "spike_view_by" + }, + "title": "View by" + } + }, + "layout": { + "type": "grid", + "globalInputs": [ + "spike_side_panel_input", + "input1" + ], + "structure": [ + { + "item": "spike_side_panel_data_volume_viz", + "position": { + "x": 20, + "y": 100, + "w": 300, + "h": 400 + } + }, + { + "item": "spike_side_panel_events_count_viz", + "position": { + "x": 20, + "y": 550, + "w": 300, + "h": 400 + } + } + ] + } +} \ No newline at end of file From b0a37f1d952dda5092ad392ccf0e9b4533eedd6f Mon Sep 17 00:00:00 2001 From: Szymon Oleksy Date: Thu, 20 Jun 2024 17:10:33 +0200 Subject: [PATCH 03/25] feat: spike for dashboard side panel --- splunk_add_on_ucc_framework/dashboard.py | 7 + ui/src/components/table/CustomTable.jsx | 43 +++--- ui/src/components/table/TableExpansionRow.jsx | 2 + ui/src/pages/Dashboard/DashboardPage.tsx | 46 +++---- ui/src/pages/Dashboard/DataIngestion.tsx | 126 ++++++++++++++---- ui/src/pages/Dashboard/EventHandlers.ts | 26 ---- ui/src/pages/Dashboard/SideCardPanel.tsx | 59 ++++++++ ui/src/pages/Dashboard/SpikeModal.tsx | 29 ++++ ui/src/pages/Dashboard/dashboardStyle.css | 30 ++++- ui/src/pages/Dashboard/utils.tsx | 23 ++++ 10 files changed, 292 insertions(+), 99 deletions(-) delete mode 100644 ui/src/pages/Dashboard/EventHandlers.ts create mode 100644 ui/src/pages/Dashboard/SideCardPanel.tsx create mode 100644 ui/src/pages/Dashboard/SpikeModal.tsx diff --git a/splunk_add_on_ucc_framework/dashboard.py b/splunk_add_on_ucc_framework/dashboard.py index 02107d856..2f89abcd6 100644 --- a/splunk_add_on_ucc_framework/dashboard.py +++ b/splunk_add_on_ucc_framework/dashboard.py @@ -47,6 +47,7 @@ "data_ingestion_tab": "data_ingestion_tab_definition.json", "errors_tab": "errors_tab_definition.json", "resources_tab": "resources_tab_definition.json", + "spike_side_panel_definition": "spike_side_panel_definition.json", } data_ingestion = ( @@ -214,6 +215,12 @@ def generate_dashboard_content( ) ) + if ( + definition_json_name + == default_definition_json_filename["spike_side_panel_definition"] + ): + content = utils.get_j2_env().get_template(definition_json_name).render() + return content diff --git a/ui/src/components/table/CustomTable.jsx b/ui/src/components/table/CustomTable.jsx index 77e0e68c8..fc72acdcf 100644 --- a/ui/src/components/table/CustomTable.jsx +++ b/ui/src/components/table/CustomTable.jsx @@ -222,26 +222,29 @@ function CustomTable({ {data && data.length && - data.map((row) => ( - - ))} + data.map((row) => { + console.log('row,row', row); + return ( + + ); + })} ); diff --git a/ui/src/components/table/TableExpansionRow.jsx b/ui/src/components/table/TableExpansionRow.jsx index 0e401e4fd..1b5be01f8 100644 --- a/ui/src/components/table/TableExpansionRow.jsx +++ b/ui/src/components/table/TableExpansionRow.jsx @@ -40,6 +40,8 @@ export function getExpansionRow(colSpan, row, moreInfo) { ? inputs.table.customRow : inputs.services.find((service) => service.name === row.serviceName).table?.customRow; + console.log('row getExpansionRow', { row, moreInfo }); + return ( diff --git a/ui/src/pages/Dashboard/DashboardPage.tsx b/ui/src/pages/Dashboard/DashboardPage.tsx index a98346471..acd55221a 100644 --- a/ui/src/pages/Dashboard/DashboardPage.tsx +++ b/ui/src/pages/Dashboard/DashboardPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import TabLayout from '@splunk/react-ui/TabLayout'; + import ErrorBoundary from '../../components/ErrorBoundary/ErrorBoundary'; import { OverviewDashboard } from './Overview'; import { DataIngestionDashboard } from './DataIngestion'; @@ -7,24 +8,7 @@ import { ErrorDashboard } from './Error'; import { ResourceDashboard } from './Resource'; import { CustomDashboard } from './Custom'; import './dashboardStyle.css'; -import { getBuildDirPath } from '../../util/script'; - -/** - * - * @param {string} fileName name of json file in custom dir - * @param {string} setData callback, called with data as params - */ -function loadJson(fileName: string, dataHandler: (data: Record) => void) { - fetch(/* webpackIgnore: true */ `${getBuildDirPath()}/custom/${fileName}`) - .then((res) => res.json()) - .then((external) => { - dataHandler(external); - }) - .catch((e) => { - // eslint-disable-next-line no-console - console.error('Loading file failed: ', e); - }); -} +import { loadDashboardJsonDefinition } from './utils'; function DashboardPage() { const [overviewDef, setOverviewDef] = useState | null>(null); @@ -34,17 +18,23 @@ function DashboardPage() { const [customDef, setCustomDef] = useState | null>(null); useEffect(() => { - loadJson('panels_to_display.json', (data: { default?: boolean; custom?: boolean }) => { - if (data?.default) { - loadJson('overview_definition.json', setOverviewDef); - loadJson('data_ingestion_tab_definition.json', setDataIngestionDef); - loadJson('errors_tab_definition.json', setErrorDef); - loadJson('resources_tab_definition.json', setResourceDef); - } - if (data?.custom) { - loadJson('custom.json', setCustomDef); + loadDashboardJsonDefinition( + 'panels_to_display.json', + (data: { default?: boolean; custom?: boolean }) => { + if (data?.default) { + loadDashboardJsonDefinition('overview_definition.json', setOverviewDef); + loadDashboardJsonDefinition( + 'data_ingestion_tab_definition.json', + setDataIngestionDef + ); + loadDashboardJsonDefinition('errors_tab_definition.json', setErrorDef); + loadDashboardJsonDefinition('resources_tab_definition.json', setResourceDef); + } + if (data?.custom) { + loadDashboardJsonDefinition('custom.json', setCustomDef); + } } - }); + ); document.body.classList.add('grey_background'); return () => { diff --git a/ui/src/pages/Dashboard/DataIngestion.tsx b/ui/src/pages/Dashboard/DataIngestion.tsx index 2dce1df92..9df964978 100644 --- a/ui/src/pages/Dashboard/DataIngestion.tsx +++ b/ui/src/pages/Dashboard/DataIngestion.tsx @@ -1,21 +1,24 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DashboardCore } from '@splunk/dashboard-core'; import { DashboardContextProvider } from '@splunk/dashboard-context'; import EnterpriseViewOnlyPreset from '@splunk/dashboard-presets/EnterpriseViewOnlyPreset'; import Search from '@splunk/react-ui/Search'; import Message from '@splunk/react-ui/Message'; import type { DashboardCoreApi } from '@splunk/dashboard-types'; -import Card from '@splunk/react-ui/Card'; import Button from '@splunk/react-ui/Button'; import { debounce } from 'lodash'; +import TabLayout from '@splunk/react-ui/TabLayout'; import { createNewQueryBasedOnSearchAndHideTraffic, getActionButtons, makeVisualAdjustmentsOnDataIngestionPage, addDescriptionToExpandedViewByOptions, + loadDashboardJsonDefinition, } from './utils'; -import TableClickHandler from './EventHandlers'; +import { CustomDashboard } from './Custom'; +import { SpikeModal } from './SpikeModal'; +import { SideCardPanel } from './SideCardPanel'; const VIEW_BY_INFO_MAP: Record = { Input: 'Volume metrics are not available when the Input view is selected.', @@ -29,12 +32,15 @@ export const DataIngestionDashboard = ({ dashboardDefinition: Record; }) => { const dashboardCoreApi = React.useRef(); - const [showCard, setShowCard] = useState(false); - const [selectedInput, setSelectedInput] = useState(''); const [searchInput, setSearchInput] = useState(''); const [viewByInput, setViewByInput] = useState(''); const [toggleNoTraffic, setToggleNoTraffic] = useState(false); + const [spikeDef, setSpikeDef] = useState | null>(null); + + const [useSideModalModalVersion, setUseSideModalModalVersion] = useState(true); + const [displaySideMenuForInput, setDisplaySideMenuForInput] = useState(null); + useEffect(() => { makeVisualAdjustmentsOnDataIngestionPage(); @@ -69,6 +75,8 @@ export const DataIngestionDashboard = ({ setViewByInput(currentViewBy || ''); + loadDashboardJsonDefinition('spike_side_panel_definition.json', setSpikeDef); + return () => { observer.disconnect(); }; @@ -113,18 +121,60 @@ export const DataIngestionDashboard = ({ const infoMessage = VIEW_BY_INFO_MAP[viewByInput]; + const handleDashboardEvent = useCallback((event) => { + // eslint-disable-next-line no-console + console.log('clicked', { + ...event, + }); + + if ( + event.type === 'cell.click' && + event.targetId === 'data_ingestion_table_viz' && + event.payload.cellIndex === 0 && + event.payload.value + ) { + setDisplaySideMenuForInput(event.payload.value); + } + + // setDisplaySideMenuForInput + }, []); + + const dashboardPlugin = useMemo( + () => ({ + onEventTrigger: handleDashboardEvent, + }), + [handleDashboardEvent] + ); return ( <> <> +
*/} - {showCard ? ( - - } - /> - - {selectedInput} - - - - - - ) : null} + {/* + setDisplaySideMenuForInput(null)} + /> + } + /> + +
+ {!useSpikeModalVersion && !!displaySideMenuForInput && ( + + + + )} +
+
+ + + +
*/} + {/* {!useSpikeModalVersion && !!displaySideMenuForInput ? ( */} + + {/* ) : null} */}
{infoMessage ? ( diff --git a/ui/src/pages/Dashboard/EventHandlers.ts b/ui/src/pages/Dashboard/EventHandlers.ts deleted file mode 100644 index 36c009e9a..000000000 --- a/ui/src/pages/Dashboard/EventHandlers.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable class-methods-use-this */ -class TableClickHandler { - options?: { field?: string }; - - constructor(options?: Record | undefined) { - this.options = options; - } - - canHandle(event: { type: string; payload?: Record }) { - // eslint-disable-next-line no-console - console.log('clicked', { - cellIndex: event?.payload?.cellIndex, - rowIndex: event?.payload?.rowIndex, - value: event?.payload?.value, - fieldValue: event?.payload?.fieldValue, - }); - return event.type === 'cell.click'; - } - - handle() { - // method never used - return new Promise((r) => r(1)); - } -} - -export default TableClickHandler; diff --git a/ui/src/pages/Dashboard/SideCardPanel.tsx b/ui/src/pages/Dashboard/SideCardPanel.tsx new file mode 100644 index 000000000..561053588 --- /dev/null +++ b/ui/src/pages/Dashboard/SideCardPanel.tsx @@ -0,0 +1,59 @@ +import Button from '@splunk/react-ui/Button'; +import Card from '@splunk/react-ui/Card'; +import TabLayout from '@splunk/react-ui/TabLayout'; +import React, { useEffect } from 'react'; +import { CustomDashboard } from './Custom'; + +interface ISideCardPanelProps { + setDisplaySideMenuForInput: (x: string | null) => void; + spikeDef: Record | null; + display?: boolean; + displaySideMenuForInput: string | null; +} + +export const SideCardPanel = ({ + setDisplaySideMenuForInput, + spikeDef, + display, + displaySideMenuForInput, +}: ISideCardPanelProps) => { + useEffect(() => { + if (!display) { + return () => {}; + } + const table = document.querySelector('#data_ingestion_table_viz') as HTMLElement; + if (table?.style) { + table.style.gridColumn = '1 / 7'; + } + + return () => { + if (table?.style) { + table.style.gridColumn = ''; + } + }; + }, [display]); + + return ( + + setDisplaySideMenuForInput(null)} /> + } + /> + +
+ + + +
+
+ + + +
+ ); +}; diff --git a/ui/src/pages/Dashboard/SpikeModal.tsx b/ui/src/pages/Dashboard/SpikeModal.tsx new file mode 100644 index 000000000..ae9e5b02b --- /dev/null +++ b/ui/src/pages/Dashboard/SpikeModal.tsx @@ -0,0 +1,29 @@ +import Modal from '@splunk/react-ui/Modal'; +import React, { ReactElement } from 'react'; +import styled from 'styled-components'; + +import { StyledButton } from '../EntryPageStyle'; + +const ModalWrapper = styled(Modal)` + width: 80%; +`; + +export const SpikeModal = (props: { + open: boolean | undefined; + handleRequestClose: () => void; + title: string | undefined; + acceptBtnLabel: string; + children: ReactElement; +}) => ( + + + {props.children} + + + + +); diff --git a/ui/src/pages/Dashboard/dashboardStyle.css b/ui/src/pages/Dashboard/dashboardStyle.css index a3dc3eb89..df50ab5a0 100644 --- a/ui/src/pages/Dashboard/dashboardStyle.css +++ b/ui/src/pages/Dashboard/dashboardStyle.css @@ -57,7 +57,7 @@ body { /* data ingestion */ [data-input-id='data_ingestion_input'] { - grid-column: 7 / 8; + grid-column: 8 / 9; grid-row: 2; } @@ -79,7 +79,7 @@ body { } #data_ingestion_table_viz { - grid-column: 1 / 8; + grid-column: 1 / 9; height: 100%; max-height: 500px; grid-row: 6; @@ -93,11 +93,13 @@ body { #data_ingestion_data_volume_viz { grid-row: 3 / 6; margin-top: 20px; + grid-column: 1 / 5; } #data_ingestion_events_count_viz { grid-row: 3 / 6; margin-top: 20px; + grid-column: 5 / 9; } #data_ingestion_search { @@ -309,3 +311,27 @@ body { #errors_tab_errors_list_viz svg[data-test='placeholder-icon'] { max-height: 400px; } + +[data-test-panel-id='dataIngestionTabPanel'] [data-test='grid-layout-canvas'] { + grid-template-columns: repeat(8, 12%); + /* grid-template-rows: auto auto auto auto auto; */ + overflow-x: hidden; +} + +#SpikeSidePanel [data-test='grid-layout-canvas'] { + grid-template-columns: 50% 50%; + /* grid-template-rows: auto auto auto auto auto; */ + overflow-x: hidden; +} + +#spike_side_panel_data_volume_viz, +#spike_side_panel_events_count_viz { + grid-column: 1 / span 2; +} + +#spikeCardSidePanel{ + grid-column: 7 / 9; + grid-row: 7; + margin-top: 72px; + z-index: 999; +} \ No newline at end of file diff --git a/ui/src/pages/Dashboard/utils.tsx b/ui/src/pages/Dashboard/utils.tsx index 7a2580162..927efded3 100644 --- a/ui/src/pages/Dashboard/utils.tsx +++ b/ui/src/pages/Dashboard/utils.tsx @@ -1,5 +1,26 @@ import { RefreshButton, ExportButton, OpenSearchButton } from '@splunk/dashboard-action-buttons'; import React from 'react'; +import { getBuildDirPath } from '../../util/script'; + +/** + * + * @param {string} fileName name of json file in custom dir + * @param {string} setData callback, called with data as params + */ +export function loadDashboardJsonDefinition( + fileName: string, + dataHandler: (data: Record) => void +) { + fetch(/* webpackIgnore: true */ `${getBuildDirPath()}/custom/${fileName}`) + .then((res) => res.json()) + .then((external) => { + dataHandler(external); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error('Loading file failed: ', e); + }); +} export const waitForElementToDisplay = ( selector: string, @@ -156,6 +177,8 @@ export const makeVisualAdjustmentsOnDataIngestionPage = () => { '#info_message_for_data_ingestion', '#data_ingestion_table_viz div' ); + + waitForElementToDisplayAndMoveThemToCanvas('#spikeCardSidePanel', '#data_ingestion_table_viz'); }; const VIEW_BY_EXTRA_LABEL_DESC: Record = { From 03941cc920fa154542c625f284fb9737427ba020 Mon Sep 17 00:00:00 2001 From: rohanm-crest Date: Fri, 16 Aug 2024 14:35:10 +0530 Subject: [PATCH 04/25] feat(timerange): added timeframe, new search button and refactor code for modal view --- splunk_add_on_ucc_framework/dashboard.py | 4 +- ...ition.json => spike_modal_definition.json} | 113 +++++++++++++----- ui/src/pages/Dashboard/DataIngestion.tsx | 82 +------------ ui/src/pages/Dashboard/SideCardPanel.tsx | 59 --------- ui/src/pages/Dashboard/SpikeModal.tsx | 28 ++++- ui/src/pages/Dashboard/dashboardStyle.css | 36 +++--- ui/src/pages/Dashboard/utils.tsx | 2 - 7 files changed, 128 insertions(+), 196 deletions(-) rename splunk_add_on_ucc_framework/templates/{spike_side_panel_definition.json => spike_modal_definition.json} (51%) delete mode 100644 ui/src/pages/Dashboard/SideCardPanel.tsx diff --git a/splunk_add_on_ucc_framework/dashboard.py b/splunk_add_on_ucc_framework/dashboard.py index 2f89abcd6..451a4de94 100644 --- a/splunk_add_on_ucc_framework/dashboard.py +++ b/splunk_add_on_ucc_framework/dashboard.py @@ -47,7 +47,7 @@ "data_ingestion_tab": "data_ingestion_tab_definition.json", "errors_tab": "errors_tab_definition.json", "resources_tab": "resources_tab_definition.json", - "spike_side_panel_definition": "spike_side_panel_definition.json", + "spike_modal_definition": "spike_modal_definition.json", } data_ingestion = ( @@ -217,7 +217,7 @@ def generate_dashboard_content( if ( definition_json_name - == default_definition_json_filename["spike_side_panel_definition"] + == default_definition_json_filename["spike_modal_definition"] ): content = utils.get_j2_env().get_template(definition_json_name).render() diff --git a/splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json b/splunk_add_on_ucc_framework/templates/spike_modal_definition.json similarity index 51% rename from splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json rename to splunk_add_on_ucc_framework/templates/spike_modal_definition.json index 6a7ad6957..d9588a82c 100644 --- a/splunk_add_on_ucc_framework/templates/spike_side_panel_definition.json +++ b/splunk_add_on_ucc_framework/templates/spike_modal_definition.json @@ -1,28 +1,46 @@ { "visualizations": { - "spike_side_panel_data_volume_viz": { + "spike_modal_timerange_label_start_viz": { + "type": "splunk.singlevalue", + "options": { + "majorFontSize": 12, + "backgroundColor": "transparent", + "majorColor": "#9fa4af" + }, + "dataSources": { + "primary": "spike_modal_data_time_label_start_ds" + } + }, + "spike_modal_timerange_label_end_viz": { + "type": "splunk.singlevalue", + "options": { + "majorFontSize": 12, + "backgroundColor": "transparent", + "majorColor": "#9fa4af" + }, + "dataSources": { + "primary": "spike_modal_data_time_label_end_ds" + } + }, + "spike_modal_data_volume_viz": { "type": "splunk.line", "options": { "xAxisVisibility": "hide", - "seriesColors": [ - "#A870EF" - ], + "seriesColors": ["#A870EF"], "yAxisTitleText": "Volume (bytes)", "xAxisTitleText": "Time" }, "title": "Data volume", "dataSources": { - "primary": "spike_side_panel_data_volume_ds" + "primary": "spike_modal_data_volume_ds" } }, - "spike_side_panel_events_count_viz": { + "spike_modal_events_count_viz": { "type": "splunk.line", "options": { "xAxisVisibility": "hide", "xAxisTitleText": "Time", - "seriesColors": [ - "#A870EF" - ], + "seriesColors": ["#A870EF"], "yAxisTitleText": "Number of events" }, "title": "Number of events", @@ -32,13 +50,33 @@ } }, "dataSources": { - "spike_side_panel_data_volume_ds": { + "spike_modal_data_time_label_start_ds": { + "type": "ds.search", + "options": { + "query": "| makeresults | addinfo | eval StartDate = strftime(info_min_time, \"%e %b %Y %I:%M%p\") | table StartDate", + "queryParameters": { + "earliest": "$spike_modal_time.earliest$", + "latest": "$spike_modal_time.latest$" + } + } + }, + "spike_modal_data_time_label_end_ds": { + "type": "ds.search", + "options": { + "query": "| makeresults | addinfo | eval EndDate = strftime(info_max_time, \"%e %b %Y %I:%M%p\") | table EndDate", + "queryParameters": { + "earliest": "$spike_modal_time.earliest$", + "latest": "$spike_modal_time.latest$" + } + } + }, + "spike_modal_data_volume_ds": { "type": "ds.search", "options": { "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", "queryParameters": { - "earliest": "$spike_side_panel_time.earliest$", - "latest": "$spike_side_panel_time.latest$" + "earliest": "$spike_modal_time.earliest$", + "latest": "$spike_modal_time.latest$" } } }, @@ -47,8 +85,8 @@ "options": { "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", "queryParameters": { - "earliest": "$spike_side_panel_time.earliest$", - "latest": "$spike_side_panel_time.latest$" + "earliest": "$spike_modal_time.earliest$", + "latest": "$spike_modal_time.latest$" } }, "name": "Security Score vs Spend" @@ -56,14 +94,6 @@ }, "defaults": {}, "inputs": { - "spike_side_panel_input": { - "options": { - "defaultValue": "-24h,now", - "token": "spike_side_panel_time" - }, - "title": "Time", - "type": "input.timerange" - }, "input1": { "type": "input.dropdown", "options": { @@ -84,18 +114,41 @@ "defaultValue": "Source type", "token": "spike_view_by" }, - "title": "View by" + "title": "Input" + }, + "spike_modal_input": { + "options": { + "defaultValue": "-24h,now", + "token": "spike_modal_time" + }, + "title": "Time Window", + "type": "input.timerange" } }, "layout": { "type": "grid", - "globalInputs": [ - "spike_side_panel_input", - "input1" - ], + "globalInputs": ["spike_modal_input", "input1"], "structure": [ { - "item": "spike_side_panel_data_volume_viz", + "item": "spike_modal_timerange_label_start_viz", + "position": { + "x": 20, + "y": 0, + "w": 100, + "h": 20 + } + }, + { + "item": "spike_modal_timerange_label_end_viz", + "position": { + "x": 120, + "y": 0, + "w": 100, + "h": 20 + } + }, + { + "item": "spike_modal_data_volume_viz", "position": { "x": 20, "y": 100, @@ -104,7 +157,7 @@ } }, { - "item": "spike_side_panel_events_count_viz", + "item": "spike_modal_events_count_viz", "position": { "x": 20, "y": 550, @@ -114,4 +167,4 @@ } ] } -} \ No newline at end of file +} diff --git a/ui/src/pages/Dashboard/DataIngestion.tsx b/ui/src/pages/Dashboard/DataIngestion.tsx index 9df964978..c9407a9af 100644 --- a/ui/src/pages/Dashboard/DataIngestion.tsx +++ b/ui/src/pages/Dashboard/DataIngestion.tsx @@ -5,7 +5,6 @@ import EnterpriseViewOnlyPreset from '@splunk/dashboard-presets/EnterpriseViewOn import Search from '@splunk/react-ui/Search'; import Message from '@splunk/react-ui/Message'; import type { DashboardCoreApi } from '@splunk/dashboard-types'; -import Button from '@splunk/react-ui/Button'; import { debounce } from 'lodash'; import TabLayout from '@splunk/react-ui/TabLayout'; @@ -18,7 +17,6 @@ import { } from './utils'; import { CustomDashboard } from './Custom'; import { SpikeModal } from './SpikeModal'; -import { SideCardPanel } from './SideCardPanel'; const VIEW_BY_INFO_MAP: Record = { Input: 'Volume metrics are not available when the Input view is selected.', @@ -35,10 +33,7 @@ export const DataIngestionDashboard = ({ const [searchInput, setSearchInput] = useState(''); const [viewByInput, setViewByInput] = useState(''); const [toggleNoTraffic, setToggleNoTraffic] = useState(false); - const [spikeDef, setSpikeDef] = useState | null>(null); - - const [useSideModalModalVersion, setUseSideModalModalVersion] = useState(true); const [displaySideMenuForInput, setDisplaySideMenuForInput] = useState(null); useEffect(() => { @@ -75,7 +70,7 @@ export const DataIngestionDashboard = ({ setViewByInput(currentViewBy || ''); - loadDashboardJsonDefinition('spike_side_panel_definition.json', setSpikeDef); + loadDashboardJsonDefinition('spike_modal_definition.json', setSpikeDef); return () => { observer.disconnect(); @@ -140,35 +135,24 @@ export const DataIngestionDashboard = ({ }, []); const dashboardPlugin = useMemo( - () => ({ - onEventTrigger: handleDashboardEvent, - }), + () => ({ onEventTrigger: handleDashboardEvent }), [handleDashboardEvent] ); return ( <> <> -
- {/*
- - Hide items with no traffic - -
*/} - {/* - setDisplaySideMenuForInput(null)} - /> - } - /> - -
- {!useSpikeModalVersion && !!displaySideMenuForInput && ( - - - - )} -
-
- - - -
*/} - {/* {!useSpikeModalVersion && !!displaySideMenuForInput ? ( */} - - {/* ) : null} */}
{infoMessage ? ( diff --git a/ui/src/pages/Dashboard/SideCardPanel.tsx b/ui/src/pages/Dashboard/SideCardPanel.tsx deleted file mode 100644 index 561053588..000000000 --- a/ui/src/pages/Dashboard/SideCardPanel.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import Button from '@splunk/react-ui/Button'; -import Card from '@splunk/react-ui/Card'; -import TabLayout from '@splunk/react-ui/TabLayout'; -import React, { useEffect } from 'react'; -import { CustomDashboard } from './Custom'; - -interface ISideCardPanelProps { - setDisplaySideMenuForInput: (x: string | null) => void; - spikeDef: Record | null; - display?: boolean; - displaySideMenuForInput: string | null; -} - -export const SideCardPanel = ({ - setDisplaySideMenuForInput, - spikeDef, - display, - displaySideMenuForInput, -}: ISideCardPanelProps) => { - useEffect(() => { - if (!display) { - return () => {}; - } - const table = document.querySelector('#data_ingestion_table_viz') as HTMLElement; - if (table?.style) { - table.style.gridColumn = '1 / 7'; - } - - return () => { - if (table?.style) { - table.style.gridColumn = ''; - } - }; - }, [display]); - - return ( - - setDisplaySideMenuForInput(null)} /> - } - /> - -
- - - -
-
- - - -
- ); -}; diff --git a/ui/src/pages/Dashboard/SpikeModal.tsx b/ui/src/pages/Dashboard/SpikeModal.tsx index ae9e5b02b..056aba204 100644 --- a/ui/src/pages/Dashboard/SpikeModal.tsx +++ b/ui/src/pages/Dashboard/SpikeModal.tsx @@ -1,11 +1,18 @@ import Modal from '@splunk/react-ui/Modal'; import React, { ReactElement } from 'react'; import styled from 'styled-components'; +// import Button from '@splunk/react-ui/Button'; import { StyledButton } from '../EntryPageStyle'; const ModalWrapper = styled(Modal)` - width: 80%; + width: 60%; + height: 80%; + margin-top: 8vh; +`; + +const StyledDiv = styled('div')` + display: flex; `; export const SpikeModal = (props: { @@ -19,11 +26,20 @@ export const SpikeModal = (props: { {props.children} - + + + + ); diff --git a/ui/src/pages/Dashboard/dashboardStyle.css b/ui/src/pages/Dashboard/dashboardStyle.css index df50ab5a0..855493d59 100644 --- a/ui/src/pages/Dashboard/dashboardStyle.css +++ b/ui/src/pages/Dashboard/dashboardStyle.css @@ -239,6 +239,17 @@ body { height: 300px; } + +#spike_modal_timerange_label_start_viz { + grid-row: 1; +} + +#spike_modal_timerange_label_end_viz{ + grid-row: 1; + margin-left: 145px; + display: flex; +} + /* shared styles for time labels */ #overview_timerange_label_start_viz, #overview_timerange_label_end_viz, @@ -247,7 +258,9 @@ body { #errors_tab_timerange_label_start_viz, #errors_tab_timerange_label_end_viz, #resource_tab_timerange_label_start_viz, -#resource_tab_timerange_label_end_viz { +#resource_tab_timerange_label_end_viz, +#spike_modal_timerange_label_start_viz, +#spike_modal_timerange_label_end_viz { inline-size: fit-content; grid-column: 1; width: 145px; @@ -302,7 +315,8 @@ body { #overview_timerange_label_end_viz::before, #data_ingestion_timerange_label_end_viz::before, #errors_tab_timerange_label_end_viz::before, -#resource_tab_timerange_label_end_viz::before { +#resource_tab_timerange_label_end_viz::before, +#spike_modal_timerange_label_end_viz::before { content: '- '; margin: auto; color: var(--dashboard-ucc-grey-color); @@ -317,21 +331,3 @@ body { /* grid-template-rows: auto auto auto auto auto; */ overflow-x: hidden; } - -#SpikeSidePanel [data-test='grid-layout-canvas'] { - grid-template-columns: 50% 50%; - /* grid-template-rows: auto auto auto auto auto; */ - overflow-x: hidden; -} - -#spike_side_panel_data_volume_viz, -#spike_side_panel_events_count_viz { - grid-column: 1 / span 2; -} - -#spikeCardSidePanel{ - grid-column: 7 / 9; - grid-row: 7; - margin-top: 72px; - z-index: 999; -} \ No newline at end of file diff --git a/ui/src/pages/Dashboard/utils.tsx b/ui/src/pages/Dashboard/utils.tsx index 927efded3..bb31e317b 100644 --- a/ui/src/pages/Dashboard/utils.tsx +++ b/ui/src/pages/Dashboard/utils.tsx @@ -177,8 +177,6 @@ export const makeVisualAdjustmentsOnDataIngestionPage = () => { '#info_message_for_data_ingestion', '#data_ingestion_table_viz div' ); - - waitForElementToDisplayAndMoveThemToCanvas('#spikeCardSidePanel', '#data_ingestion_table_viz'); }; const VIEW_BY_EXTRA_LABEL_DESC: Record = { From dc90b5ea88dae90a75676abdb67175dec4876c7e Mon Sep 17 00:00:00 2001 From: rohanm-crest Date: Tue, 20 Aug 2024 14:48:11 +0530 Subject: [PATCH 05/25] refactor: change the background color for modal timeframe and visualizations --- .../templates/spike_modal_definition.json | 6 ++++-- ui/src/pages/Dashboard/dashboardStyle.css | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/splunk_add_on_ucc_framework/templates/spike_modal_definition.json b/splunk_add_on_ucc_framework/templates/spike_modal_definition.json index d9588a82c..ca51c97c4 100644 --- a/splunk_add_on_ucc_framework/templates/spike_modal_definition.json +++ b/splunk_add_on_ucc_framework/templates/spike_modal_definition.json @@ -28,7 +28,8 @@ "xAxisVisibility": "hide", "seriesColors": ["#A870EF"], "yAxisTitleText": "Volume (bytes)", - "xAxisTitleText": "Time" + "xAxisTitleText": "Time", + "backgroundColor": "#F7F8FA" }, "title": "Data volume", "dataSources": { @@ -41,7 +42,8 @@ "xAxisVisibility": "hide", "xAxisTitleText": "Time", "seriesColors": ["#A870EF"], - "yAxisTitleText": "Number of events" + "yAxisTitleText": "Number of events", + "backgroundColor": "#F7F8FA" }, "title": "Number of events", "dataSources": { diff --git a/ui/src/pages/Dashboard/dashboardStyle.css b/ui/src/pages/Dashboard/dashboardStyle.css index 855493d59..0d766f018 100644 --- a/ui/src/pages/Dashboard/dashboardStyle.css +++ b/ui/src/pages/Dashboard/dashboardStyle.css @@ -331,3 +331,8 @@ body { /* grid-template-rows: auto auto auto auto auto; */ overflow-x: hidden; } + +[data-test-panel-id='spikeDefTabPanel'] [data-test='input-layout-container'], +[data-test-panel-id='spikeDefTabPanel'] [data-test='grid-layout-canvas'] { + background-color: white; +} From 4d1e5117ce75681a965265cfa82c913d899c8c38 Mon Sep 17 00:00:00 2001 From: rohanm-crest Date: Thu, 29 Aug 2024 17:43:15 +0530 Subject: [PATCH 06/25] refactor: added styling to the button in the modal footer --- .../templates/spike_modal_definition.json | 18 +++++++++--------- ui/src/pages/Dashboard/SpikeModal.tsx | 18 ++++++++++++++++-- ui/src/pages/Dashboard/dashboardStyle.css | 3 ++- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/splunk_add_on_ucc_framework/templates/spike_modal_definition.json b/splunk_add_on_ucc_framework/templates/spike_modal_definition.json index ca51c97c4..17fd1c8e9 100644 --- a/splunk_add_on_ucc_framework/templates/spike_modal_definition.json +++ b/splunk_add_on_ucc_framework/templates/spike_modal_definition.json @@ -129,13 +129,13 @@ }, "layout": { "type": "grid", - "globalInputs": ["spike_modal_input", "input1"], + "globalInputs": ["input1", "spike_modal_input"], "structure": [ { "item": "spike_modal_timerange_label_start_viz", "position": { - "x": 20, - "y": 0, + "x": 0, + "y": 50, "w": 100, "h": 20 } @@ -143,8 +143,8 @@ { "item": "spike_modal_timerange_label_end_viz", "position": { - "x": 120, - "y": 0, + "x": 100, + "y": 50, "w": 100, "h": 20 } @@ -152,8 +152,8 @@ { "item": "spike_modal_data_volume_viz", "position": { - "x": 20, - "y": 100, + "x": 0, + "y": 80, "w": 300, "h": 400 } @@ -161,8 +161,8 @@ { "item": "spike_modal_events_count_viz", "position": { - "x": 20, - "y": 550, + "x": 0, + "y": 500, "w": 300, "h": 400 } diff --git a/ui/src/pages/Dashboard/SpikeModal.tsx b/ui/src/pages/Dashboard/SpikeModal.tsx index 056aba204..11bda000b 100644 --- a/ui/src/pages/Dashboard/SpikeModal.tsx +++ b/ui/src/pages/Dashboard/SpikeModal.tsx @@ -8,11 +8,23 @@ import { StyledButton } from '../EntryPageStyle'; const ModalWrapper = styled(Modal)` width: 60%; height: 80%; - margin-top: 8vh; + margin-top: 4vh; `; const StyledDiv = styled('div')` - display: flex; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + justify-content: space-between; + margin: 0px 10px; + + .footerBtn:first-child { + justify-self: start; + } + + .footerBtn:last-child { + justify-self: end; + } `; export const SpikeModal = (props: { @@ -28,6 +40,7 @@ export const SpikeModal = (props: { Date: Thu, 29 Aug 2024 12:17:57 +0000 Subject: [PATCH 07/25] update screenshots --- .../__images__/DashboardPage-dashboard-page-view-chromium.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/Dashboard/stories/__images__/DashboardPage-dashboard-page-view-chromium.png b/ui/src/pages/Dashboard/stories/__images__/DashboardPage-dashboard-page-view-chromium.png index aef1ca1ac..04dfb6f2e 100644 --- a/ui/src/pages/Dashboard/stories/__images__/DashboardPage-dashboard-page-view-chromium.png +++ b/ui/src/pages/Dashboard/stories/__images__/DashboardPage-dashboard-page-view-chromium.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17c26b26a1e89743412d372462e05c4df6c4132583e6925531851ca44c789e8b -size 83057 +oid sha256:a59a334db643d9bf14246570981ef4e1b0741cff2fb890902f3e3e05dbb29a57 +size 83740 From 74a4801cae24f25406e87b1ec1b6f9600f7183e9 Mon Sep 17 00:00:00 2001 From: rohanm-crest Date: Tue, 3 Sep 2024 19:05:09 +0530 Subject: [PATCH 08/25] feat(modal): added styling to search ingested events button --- splunk_add_on_ucc_framework/dashboard.py | 4 +- ...n => data_ingestion_modal_definition.json} | 58 ++++++++------- ui/src/components/table/TableExpansionRow.jsx | 2 - ui/src/pages/Dashboard/Custom.tsx | 7 +- ui/src/pages/Dashboard/DataIngestion.tsx | 30 ++++---- ui/src/pages/Dashboard/DataIngestionModal.tsx | 72 +++++++++++++++++++ ui/src/pages/Dashboard/SpikeModal.tsx | 59 --------------- ui/src/pages/Dashboard/dashboardStyle.css | 23 ++++-- 8 files changed, 141 insertions(+), 114 deletions(-) rename splunk_add_on_ucc_framework/templates/{spike_modal_definition.json => data_ingestion_modal_definition.json} (70%) create mode 100644 ui/src/pages/Dashboard/DataIngestionModal.tsx delete mode 100644 ui/src/pages/Dashboard/SpikeModal.tsx diff --git a/splunk_add_on_ucc_framework/dashboard.py b/splunk_add_on_ucc_framework/dashboard.py index 6240546b4..ffd32e051 100644 --- a/splunk_add_on_ucc_framework/dashboard.py +++ b/splunk_add_on_ucc_framework/dashboard.py @@ -47,7 +47,7 @@ "data_ingestion_tab": "data_ingestion_tab_definition.json", "errors_tab": "errors_tab_definition.json", "resources_tab": "resources_tab_definition.json", - "spike_modal_definition": "spike_modal_definition.json", + "data_ingestion_modal_definition": "data_ingestion_modal_definition.json", } data_ingestion = ( @@ -236,7 +236,7 @@ def generate_dashboard_content( if ( definition_json_name - == default_definition_json_filename["spike_modal_definition"] + == default_definition_json_filename["data_ingestion_modal_definition"] ): content = utils.get_j2_env().get_template(definition_json_name).render() diff --git a/splunk_add_on_ucc_framework/templates/spike_modal_definition.json b/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json similarity index 70% rename from splunk_add_on_ucc_framework/templates/spike_modal_definition.json rename to splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json index 17fd1c8e9..1e3560251 100644 --- a/splunk_add_on_ucc_framework/templates/spike_modal_definition.json +++ b/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json @@ -1,6 +1,6 @@ { "visualizations": { - "spike_modal_timerange_label_start_viz": { + "data_ingestion_modal_timerange_label_start_viz": { "type": "splunk.singlevalue", "options": { "majorFontSize": 12, @@ -8,10 +8,10 @@ "majorColor": "#9fa4af" }, "dataSources": { - "primary": "spike_modal_data_time_label_start_ds" + "primary": "data_ingestion_modal_data_time_label_start_ds" } }, - "spike_modal_timerange_label_end_viz": { + "data_ingestion_modal_timerange_label_end_viz": { "type": "splunk.singlevalue", "options": { "majorFontSize": 12, @@ -19,31 +19,29 @@ "majorColor": "#9fa4af" }, "dataSources": { - "primary": "spike_modal_data_time_label_end_ds" + "primary": "data_ingestion_modal_data_time_label_end_ds" } }, - "spike_modal_data_volume_viz": { + "data_ingestion_modal_data_volume_viz": { "type": "splunk.line", "options": { "xAxisVisibility": "hide", "seriesColors": ["#A870EF"], "yAxisTitleText": "Volume (bytes)", - "xAxisTitleText": "Time", - "backgroundColor": "#F7F8FA" + "xAxisTitleText": "Time" }, "title": "Data volume", "dataSources": { - "primary": "spike_modal_data_volume_ds" + "primary": "data_ingestion_modal_data_volume_ds" } }, - "spike_modal_events_count_viz": { + "data_ingestion_modal_events_count_viz": { "type": "splunk.line", "options": { "xAxisVisibility": "hide", "xAxisTitleText": "Time", "seriesColors": ["#A870EF"], - "yAxisTitleText": "Number of events", - "backgroundColor": "#F7F8FA" + "yAxisTitleText": "Number of events" }, "title": "Number of events", "dataSources": { @@ -52,33 +50,33 @@ } }, "dataSources": { - "spike_modal_data_time_label_start_ds": { + "data_ingestion_modal_data_time_label_start_ds": { "type": "ds.search", "options": { "query": "| makeresults | addinfo | eval StartDate = strftime(info_min_time, \"%e %b %Y %I:%M%p\") | table StartDate", "queryParameters": { - "earliest": "$spike_modal_time.earliest$", - "latest": "$spike_modal_time.latest$" + "earliest": "$data_ingestion_modal_time.earliest$", + "latest": "$data_ingestion_modal_time.latest$" } } }, - "spike_modal_data_time_label_end_ds": { + "data_ingestion_modal_data_time_label_end_ds": { "type": "ds.search", "options": { "query": "| makeresults | addinfo | eval EndDate = strftime(info_max_time, \"%e %b %Y %I:%M%p\") | table EndDate", "queryParameters": { - "earliest": "$spike_modal_time.earliest$", - "latest": "$spike_modal_time.latest$" + "earliest": "$data_ingestion_modal_time.earliest$", + "latest": "$data_ingestion_modal_time.latest$" } } }, - "spike_modal_data_volume_ds": { + "data_ingestion_modal_data_volume_ds": { "type": "ds.search", "options": { "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", "queryParameters": { - "earliest": "$spike_modal_time.earliest$", - "latest": "$spike_modal_time.latest$" + "earliest": "$data_ingestion_modal_time.earliest$", + "latest": "$data_ingestion_modal_time.latest$" } } }, @@ -87,8 +85,8 @@ "options": { "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", "queryParameters": { - "earliest": "$spike_modal_time.earliest$", - "latest": "$spike_modal_time.latest$" + "earliest": "$data_ingestion_modal_time.earliest$", + "latest": "$data_ingestion_modal_time.latest$" } }, "name": "Security Score vs Spend" @@ -114,14 +112,14 @@ } ], "defaultValue": "Source type", - "token": "spike_view_by" + "token": "data_ingestion_view_by" }, "title": "Input" }, - "spike_modal_input": { + "data_ingestion_modal_input": { "options": { "defaultValue": "-24h,now", - "token": "spike_modal_time" + "token": "data_ingestion_modal_time" }, "title": "Time Window", "type": "input.timerange" @@ -129,10 +127,10 @@ }, "layout": { "type": "grid", - "globalInputs": ["input1", "spike_modal_input"], + "globalInputs": ["input1", "data_ingestion_modal_input"], "structure": [ { - "item": "spike_modal_timerange_label_start_viz", + "item": "data_ingestion_modal_timerange_label_start_viz", "position": { "x": 0, "y": 50, @@ -141,7 +139,7 @@ } }, { - "item": "spike_modal_timerange_label_end_viz", + "item": "data_ingestion_modal_timerange_label_end_viz", "position": { "x": 100, "y": 50, @@ -150,7 +148,7 @@ } }, { - "item": "spike_modal_data_volume_viz", + "item": "data_ingestion_modal_data_volume_viz", "position": { "x": 0, "y": 80, @@ -159,7 +157,7 @@ } }, { - "item": "spike_modal_events_count_viz", + "item": "data_ingestion_modal_events_count_viz", "position": { "x": 0, "y": 500, diff --git a/ui/src/components/table/TableExpansionRow.jsx b/ui/src/components/table/TableExpansionRow.jsx index 2340b3266..a2e4cb7c8 100644 --- a/ui/src/components/table/TableExpansionRow.jsx +++ b/ui/src/components/table/TableExpansionRow.jsx @@ -18,8 +18,6 @@ export function getExpansionRow(colSpan, row, moreInfo) { ? inputs.table.customRow : inputs.services.find((service) => service.name === row.serviceName).table?.customRow; - console.log('row getExpansionRow', { row, moreInfo }); - return ( diff --git a/ui/src/pages/Dashboard/Custom.tsx b/ui/src/pages/Dashboard/Custom.tsx index 8695e9c40..96b06bf7a 100644 --- a/ui/src/pages/Dashboard/Custom.tsx +++ b/ui/src/pages/Dashboard/Custom.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { DashboardCore } from '@splunk/dashboard-core'; import { DashboardContextProvider } from '@splunk/dashboard-context'; import EnterpriseViewOnlyPreset from '@splunk/dashboard-presets/EnterpriseViewOnlyPreset'; +import { getActionButtons } from './utils'; /** * @param {object} props @@ -18,6 +19,10 @@ export const CustomDashboard = ({ preset={EnterpriseViewOnlyPreset} initialDefinition={dashboardDefinition} > - + ) : null; diff --git a/ui/src/pages/Dashboard/DataIngestion.tsx b/ui/src/pages/Dashboard/DataIngestion.tsx index c9407a9af..fa3c140a5 100644 --- a/ui/src/pages/Dashboard/DataIngestion.tsx +++ b/ui/src/pages/Dashboard/DataIngestion.tsx @@ -16,7 +16,7 @@ import { loadDashboardJsonDefinition, } from './utils'; import { CustomDashboard } from './Custom'; -import { SpikeModal } from './SpikeModal'; +import { DataIngestionModal } from './DataIngestionModal'; const VIEW_BY_INFO_MAP: Record = { Input: 'Volume metrics are not available when the Input view is selected.', @@ -33,7 +33,10 @@ export const DataIngestionDashboard = ({ const [searchInput, setSearchInput] = useState(''); const [viewByInput, setViewByInput] = useState(''); const [toggleNoTraffic, setToggleNoTraffic] = useState(false); - const [spikeDef, setSpikeDef] = useState | null>(null); + const [dataIngestionModalDef, setDataIngestionModalDef] = useState | null>(null); const [displaySideMenuForInput, setDisplaySideMenuForInput] = useState(null); useEffect(() => { @@ -70,7 +73,10 @@ export const DataIngestionDashboard = ({ setViewByInput(currentViewBy || ''); - loadDashboardJsonDefinition('spike_modal_definition.json', setSpikeDef); + loadDashboardJsonDefinition( + 'data_ingestion_modal_definition.json', + setDataIngestionModalDef + ); return () => { observer.disconnect(); @@ -117,11 +123,6 @@ export const DataIngestionDashboard = ({ const infoMessage = VIEW_BY_INFO_MAP[viewByInput]; const handleDashboardEvent = useCallback((event) => { - // eslint-disable-next-line no-console - console.log('clicked', { - ...event, - }); - if ( event.type === 'cell.click' && event.targetId === 'data_ingestion_table_viz' && @@ -146,18 +147,21 @@ export const DataIngestionDashboard = ({ dashboardPlugin={dashboardPlugin} > <> - { setDisplaySideMenuForInput(null); }} - title={`Title for input - ${displaySideMenuForInput}`} + title={`${displaySideMenuForInput}`} acceptBtnLabel="Done" > - - + + - + void; + title: string | undefined; + acceptBtnLabel: string; + children: ReactElement; +}) => ( + + + {props.children} + + +
- {infoMessage ? ( + {infoMessage && ( {infoMessage} - ) : null} + )}
diff --git a/ui/src/pages/Dashboard/DataIngestionModal.tsx b/ui/src/pages/Dashboard/DataIngestionModal.tsx index 9d12f7214..3ef9ebedf 100644 --- a/ui/src/pages/Dashboard/DataIngestionModal.tsx +++ b/ui/src/pages/Dashboard/DataIngestionModal.tsx @@ -41,7 +41,7 @@ const FooterButtonGroup = styled('div')` export const DataIngestionModal = ({ open = false, handleRequestClose, - title = '', + title, acceptBtnLabel = 'Done', children, }: { diff --git a/ui/src/pages/Dashboard/utils.tsx b/ui/src/pages/Dashboard/utils.tsx index bb31e317b..464108bb6 100644 --- a/ui/src/pages/Dashboard/utils.tsx +++ b/ui/src/pages/Dashboard/utils.tsx @@ -224,3 +224,27 @@ export const addDescriptionToExpandedViewByOptions = (target: Element) => { } }); }; + +export const createNewQueryForDataVolumeInModal = ( + selectedInput: string, + selectedValue: string +) => { + const selectedLabel = queryMap[selectedInput]; + + const newQuery = `index=_internal source=*license_usage.log type=Usage ${selectedLabel} = "${selectedValue}" + | timechart sum(b) as Usage + | rename Usage as "Data volume"`; + + return newQuery; +}; + +export const createNewQueryForNumberOfEventsInModal = ( + selectedInput: string, + selectedValue: string +) => { + const selectedLabel = queryMap[selectedInput]; + + const newQuery = `index=_internal source=*splunk_ta_uccexample* action=events_ingested ${selectedLabel} = "${selectedValue}" | timechart sum(n_events) as "Number of events"`; + + return newQuery; +}; From 043307b71a4b9360f85e1f300829e68b554a6e74 Mon Sep 17 00:00:00 2001 From: rohanm-crest Date: Fri, 20 Sep 2024 18:24:33 +0530 Subject: [PATCH 15/25] feat(modal): change the logic for modal values dynamically and added queries for charts --- .../data_ingestion_modal_definition.json | 9 +- .../data_ingestion_modal_definition.json | 9 +- ui/src/pages/Dashboard/DashboardModal.tsx | 113 ++++++++++++++---- ui/src/pages/Dashboard/DataIngestion.tsx | 87 ++++++++------ ui/src/pages/Dashboard/DataIngestion.types.ts | 22 ++++ ui/src/pages/Dashboard/DataIngestionModal.tsx | 21 ++-- ui/src/pages/Dashboard/dashboardStyle.css | 4 +- ui/src/pages/Dashboard/utils.tsx | 69 ++++++++++- 8 files changed, 264 insertions(+), 70 deletions(-) create mode 100644 ui/src/pages/Dashboard/DataIngestion.types.ts diff --git a/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json b/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json index f5d68ae1f..ee68bda4b 100644 --- a/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json +++ b/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json @@ -96,7 +96,7 @@ }, "defaults": {}, "inputs": { - "input1": { + "data_ingestion_modal_dynamic_input": { "type": "input.dropdown", "options": { "items": [], @@ -104,7 +104,7 @@ }, "title": "Input" }, - "data_ingestion_modal_input": { + "data_ingestion_modal_time_window": { "options": { "defaultValue": "-24h,now", "token": "data_ingestion_modal_time" @@ -115,7 +115,10 @@ }, "layout": { "type": "grid", - "globalInputs": ["input1", "data_ingestion_modal_input"], + "globalInputs": [ + "data_ingestion_modal_dynamic_input", + "data_ingestion_modal_time_window" + ], "structure": [ { "item": "data_ingestion_modal_timerange_label_start_viz", diff --git a/tests/unit/expected_results/data_ingestion_modal_definition.json b/tests/unit/expected_results/data_ingestion_modal_definition.json index f5d68ae1f..ee68bda4b 100644 --- a/tests/unit/expected_results/data_ingestion_modal_definition.json +++ b/tests/unit/expected_results/data_ingestion_modal_definition.json @@ -96,7 +96,7 @@ }, "defaults": {}, "inputs": { - "input1": { + "data_ingestion_modal_dynamic_input": { "type": "input.dropdown", "options": { "items": [], @@ -104,7 +104,7 @@ }, "title": "Input" }, - "data_ingestion_modal_input": { + "data_ingestion_modal_time_window": { "options": { "defaultValue": "-24h,now", "token": "data_ingestion_modal_time" @@ -115,7 +115,10 @@ }, "layout": { "type": "grid", - "globalInputs": ["input1", "data_ingestion_modal_input"], + "globalInputs": [ + "data_ingestion_modal_dynamic_input", + "data_ingestion_modal_time_window" + ], "structure": [ { "item": "data_ingestion_modal_timerange_label_start_viz", diff --git a/ui/src/pages/Dashboard/DashboardModal.tsx b/ui/src/pages/Dashboard/DashboardModal.tsx index 6989260d1..7e7640bdd 100644 --- a/ui/src/pages/Dashboard/DashboardModal.tsx +++ b/ui/src/pages/Dashboard/DashboardModal.tsx @@ -1,64 +1,137 @@ -import React, { useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useEffect, useCallback, useRef, useMemo, useState } from 'react'; import { DashboardCore } from '@splunk/dashboard-core'; import { DashboardContextProvider } from '@splunk/dashboard-context'; import EnterpriseViewOnlyPreset from '@splunk/dashboard-presets/EnterpriseViewOnlyPreset'; import type { DashboardCoreApi } from '@splunk/dashboard-types'; + import { createNewQueryForDataVolumeInModal, createNewQueryForNumberOfEventsInModal, getActionButtons, + setandRemoveOptionsFromDropdown, } from './utils'; -const updateModalData = (dashboardDefinition: Record, selectedValue: string) => { - const copyDashboardDefinition = JSON.parse(JSON.stringify(dashboardDefinition)); - - const selectedInput = copyDashboardDefinition.inputs?.input1?.options?.title || ''; - const dataVolumeQuery = createNewQueryForDataVolumeInModal(selectedInput, selectedValue); - const eventsQuery = createNewQueryForNumberOfEventsInModal(selectedInput, selectedValue); - - copyDashboardDefinition.dataSources.data_ingestion_modal_data_volume_ds.options.query = - dataVolumeQuery; - copyDashboardDefinition.dataSources.ds_search_1.options.query = eventsQuery; - copyDashboardDefinition.inputs.input1.options.defaultValue = selectedValue; - return copyDashboardDefinition; -}; - /** * @param {object} props * @param {object} props.dashboardDefinition custom dashboard definition + * @param {string} props.selectedLabelForInput state for display title in the modal + * @param {object} props.setDisplayModalForInput setstate for header value of modal */ export const DashboardModal = ({ dashboardDefinition, selectedLabelForInput, + setDisplayModalForInput, }: { dashboardDefinition: Record | null; selectedLabelForInput: string; + setDisplayModalForInput: React.Dispatch>; }) => { const dashboardCoreApi = useRef(null); const setDashboardCoreApi = useCallback((api: DashboardCoreApi | null) => { dashboardCoreApi.current = api; }, []); + const [inputSelectorValue, setInputSelectorValue] = useState(''); + + const updateModalData = useCallback( + (selectedValue: string) => { + const copyDashboardDefinition = JSON.parse(JSON.stringify(dashboardDefinition)); + const selectedInput = + copyDashboardDefinition.inputs?.data_ingestion_modal_dynamic_input?.title || ''; + const dataVolumeQuery = createNewQueryForDataVolumeInModal( + selectedInput, + selectedValue + ); + copyDashboardDefinition.inputs.data_ingestion_modal_dynamic_input.options.defaultValue = + selectedValue; + const eventsQuery = createNewQueryForNumberOfEventsInModal( + selectedInput, + selectedValue + ); + + copyDashboardDefinition.dataSources.data_ingestion_modal_data_volume_ds.options.query = + dataVolumeQuery; + copyDashboardDefinition.dataSources.ds_search_1.options.query = eventsQuery; + copyDashboardDefinition.inputs.data_ingestion_modal_dynamic_input.options.selectFirstSearchResult = + false; + return copyDashboardDefinition; + }, + [dashboardDefinition] + ); + + useEffect(() => { + let observer: MutationObserver | null = null; + + const setupObserver = (targetNode: Element) => { + const config = { attributes: true }; + + const callback = (mutationsList: MutationRecord[]) => { + mutationsList.forEach((mutation: MutationRecord) => { + if (mutation.attributeName === 'aria-expanded') { + setandRemoveOptionsFromDropdown( + mutation.target as Element, + selectedLabelForInput + ); + } + }); + }; + + observer = new MutationObserver(callback); + observer.observe(targetNode, config); + }; + + const findTargetNode = () => { + const targetNode = document.querySelector( + '[data-input-id="data_ingestion_modal_dynamic_input"] button' + ); + + if (targetNode) { + setupObserver(targetNode); + const innerSpan = targetNode.querySelector('span > span'); + if (innerSpan) { + innerSpan.textContent = inputSelectorValue || selectedLabelForInput; + } + } else { + // Retry if the targetNode is not yet available + requestAnimationFrame(findTargetNode); + } + }; + + // Start finding the node after the component mounts + requestAnimationFrame(findTargetNode); + + // Cleanup observer on component unmount + return () => { + if (observer) { + observer.disconnect(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputSelectorValue]); // Update the dashboard when the definition or selected input changes useEffect(() => { if (dashboardCoreApi.current && dashboardDefinition) { - const updatedModalData = updateModalData(dashboardDefinition, selectedLabelForInput); + const updatedModalData = updateModalData(selectedLabelForInput); dashboardCoreApi.current.updateDefinition(updatedModalData); } - }, [dashboardDefinition, selectedLabelForInput]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardDefinition]); - // Event handler for input changes + // Event handler for input changes in the modal const handleDashboardEvent = useCallback( (event) => { if ( - event.targetId === 'input1' && + event.targetId === 'data_ingestion_modal_dynamic_input' && event.type === 'input.change' && dashboardCoreApi.current ) { - const updatedModalData = updateModalData(dashboardDefinition!, event.payload.value); + setInputSelectorValue(event.payload.value); + setDisplayModalForInput(event.payload.value); + const updatedModalData = updateModalData(event.payload.value); dashboardCoreApi.current.updateDefinition(updatedModalData); } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [dashboardDefinition] ); diff --git a/ui/src/pages/Dashboard/DataIngestion.tsx b/ui/src/pages/Dashboard/DataIngestion.tsx index 7e76936c3..a4926e4ca 100644 --- a/ui/src/pages/Dashboard/DataIngestion.tsx +++ b/ui/src/pages/Dashboard/DataIngestion.tsx @@ -14,9 +14,12 @@ import { makeVisualAdjustmentsOnDataIngestionPage, addDescriptionToExpandedViewByOptions, loadDashboardJsonDefinition, + queryMap, + fetchParsedValues, } from './utils'; import { DataIngestionModal } from './DataIngestionModal'; import { DashboardModal } from './DashboardModal'; +import { FieldValue } from './DataIngestion.types'; const VIEW_BY_INFO_MAP: Record = { Input: 'Volume metrics are not available when the Input view is selected.', @@ -45,6 +48,7 @@ export const DataIngestionDashboard = ({ useEffect(() => { makeVisualAdjustmentsOnDataIngestionPage(); + // Select the target node for observing mutations const targetNode = document.querySelector( '[data-input-id="data_ingestion_table_input"] button' ); @@ -52,9 +56,12 @@ export const DataIngestionDashboard = ({ const callback = (mutationsList: MutationRecord[]) => { mutationsList.forEach((mutation: MutationRecord) => { if (mutation.attributeName === 'data-test-value') { + // Update the dashboard definition dashboardCoreApi.current?.updateDefinition(dashboardDefinition); setSearchInput(''); setToggleNoTraffic(false); + + // Get the view-by option from the mutated element const viewByOption = (mutation.target as HTMLElement)?.getAttribute('label'); setViewByInput(viewByOption || ''); } @@ -63,26 +70,31 @@ export const DataIngestionDashboard = ({ } }); }; - // mutation is used to detect if dropdown value is changed - // todo: do a better solution + + // Create a MutationObserver instance and start observing const observer = new MutationObserver(callback); if (targetNode) { observer.observe(targetNode, config); } + // Set the current "view by" option when the component mounts const currentViewBy = document .querySelector('[data-input-id="data_ingestion_table_input"] button') ?.getAttribute('label'); setViewByInput(currentViewBy || ''); + // Load the dashboard definition loadDashboardJsonDefinition( 'data_ingestion_modal_definition.json', setDataIngestionModalDef ); + // Clean-up function to disconnect the observer when the component unmounts return () => { - observer.disconnect(); + if (observer && targetNode) { + observer.disconnect(); + } }; }, [dashboardDefinition]); @@ -94,26 +106,23 @@ export const DataIngestionDashboard = ({ () => debounce((searchValue, hideToggleValue) => { const copyJson = JSON.parse(JSON.stringify(dashboardDefinition)); + const selectedLabel = + document + ?.querySelector('[data-input-id="data_ingestion_table_input"] button') + ?.getAttribute('label') || 'Source type'; - if (copyJson?.inputs?.data_ingestion_table_input?.options?.items?.length > 0) { - const selectedLabel = - document - ?.querySelector('[data-input-id="data_ingestion_table_input"] button') - ?.getAttribute('label') || 'Source type'; - - const item = copyJson.inputs.data_ingestion_table_input.options.items.find( - (it: { label: string }) => it.label === selectedLabel - ); - - const newQuery = createNewQueryBasedOnSearchAndHideTraffic( - searchValue, - hideToggleValue, - item.value, - selectedLabel - ); - copyJson.dataSources.data_ingestion_table_ds.options.query = newQuery; - dashboardCoreApi.current?.updateDefinition(copyJson); - } + const item = copyJson.inputs.data_ingestion_table_input.options.items.find( + (it: { label: string }) => it.label === selectedLabel + ); + + const newQuery = createNewQueryBasedOnSearchAndHideTraffic( + searchValue, + hideToggleValue, + item.value, + selectedLabel + ); + copyJson.dataSources.data_ingestion_table_ds.options.query = newQuery; + dashboardCoreApi.current?.updateDefinition(copyJson); }, 1000), [dashboardDefinition] ); @@ -126,7 +135,7 @@ export const DataIngestionDashboard = ({ const infoMessage = VIEW_BY_INFO_MAP[viewByInput]; const handleDashboardEvent = useCallback( - (event) => { + async (event) => { if ( event.type === 'datasource.done' && event.targetId === 'data_ingestion_table_ds' && @@ -136,19 +145,29 @@ export const DataIngestionDashboard = ({ const copyDataIngestionModalJson = JSON.parse( JSON.stringify(dataIngestionModalDef) ); + const modalInputSelectorName = event.payload.data.fields[0].name; + const values = await fetchParsedValues(); + let extractColumnsValues: string[] = []; + values.results.forEach((value) => { + if (queryMap[modalInputSelectorName] === value.field) { + const dropDownValues = JSON.parse(value.values); + extractColumnsValues = dropDownValues.map((item: FieldValue) => item.value); + } + }); + setDisplayModalForInput(event.payload.value); - const columnsArray: { key: string; value: string }[] = - event.payload.data.columns[0].map((item: string) => ({ - key: item, + const columnsArray: { label: string; value: string }[] = extractColumnsValues.map( + (item: string) => ({ + label: item, value: item, - })); - - const modalInputSelectorName = event.payload.data.fields[0].name; + }) + ); // update the input selector name and value in the modal - copyDataIngestionModalJson.inputs.input1.options.title = modalInputSelectorName; - copyDataIngestionModalJson.inputs.input1.title = modalInputSelectorName; - copyDataIngestionModalJson.inputs.input1.options.items = columnsArray; + copyDataIngestionModalJson.inputs.data_ingestion_modal_dynamic_input.title = + modalInputSelectorName; + copyDataIngestionModalJson.inputs.data_ingestion_modal_dynamic_input.options.items = + columnsArray; // Modify visualizations only for specific cases if (modalInputSelectorName === 'Input') { @@ -160,9 +179,8 @@ export const DataIngestionDashboard = ({ delete copyDataIngestionModalJson.visualizations .data_ingestion_modal_events_count_viz; } - setCopyDataIngestionModalDef(copyDataIngestionModalJson); // Update state with modified copy + setCopyDataIngestionModalDef({ ...copyDataIngestionModalJson }); // Update state with modified copy } - if ( event.type === 'cell.click' && event.targetId === 'data_ingestion_table_viz' && @@ -200,6 +218,7 @@ export const DataIngestionDashboard = ({ diff --git a/ui/src/pages/Dashboard/DataIngestion.types.ts b/ui/src/pages/Dashboard/DataIngestion.types.ts new file mode 100644 index 000000000..e49b3b209 --- /dev/null +++ b/ui/src/pages/Dashboard/DataIngestion.types.ts @@ -0,0 +1,22 @@ +import { EventType } from '@splunk/react-events-viewer/common-types'; + +export interface FieldValue { + value: string; + count: number; +} + +export interface SearchMessage { + type: string; + text: string; +} + +export interface SearchResponse { + sid?: string; + fields: { name: string }[]; + highlighted?: unknown; + init_offset: number; + messages: SearchMessage[]; + preview: boolean; + post_process_count: number; + results: TResult[]; +} diff --git a/ui/src/pages/Dashboard/DataIngestionModal.tsx b/ui/src/pages/Dashboard/DataIngestionModal.tsx index 3ef9ebedf..734a1bb1b 100644 --- a/ui/src/pages/Dashboard/DataIngestionModal.tsx +++ b/ui/src/pages/Dashboard/DataIngestionModal.tsx @@ -60,13 +60,20 @@ export const DataIngestionModal = ({ id="open_search_error_events_tab_with_types" label="View ingested events in search" openInNewContext - onClick={() => - ( - document.querySelector( - '#data_ingestion_modal_events_count_viz [data-test="open-search-button"]' - ) as HTMLElement - )?.click() - } + onClick={() => { + const searchButtonForNumberOfEvents = document.querySelector( + '#data_ingestion_modal_events_count_viz [data-test="open-search-button"]' + ) as HTMLElement | null; + const searchButtonForDataVolume = document.querySelector( + '#data_ingestion_modal_data_volume_viz [data-test="open-search-button"]' + ) as HTMLElement | null; + + if (searchButtonForNumberOfEvents) { + searchButtonForNumberOfEvents.click(); + } else { + searchButtonForDataVolume?.click(); + } + }} /> = { +export const queryMap: Record = { 'Source type': 'st', Source: 's', Host: 'h', @@ -225,6 +227,39 @@ export const addDescriptionToExpandedViewByOptions = (target: Element) => { }); }; +export const setandRemoveOptionsFromDropdown = (target: Element, inputSelectorValue: string) => { + const optionPopupId = target?.getAttribute('data-test-popover-id'); + + if (!optionPopupId) { + return; + } + + const optionPopup = document.getElementById(optionPopupId); + if (!optionPopup) { + return; + } + + const allOptions = optionPopup.querySelectorAll('[role="option"]'); + allOptions.forEach((option) => { + if ( + option.getAttribute('aria-selected') === 'true' && + option.getAttribute('title') !== inputSelectorValue + ) { + const svgTick = option.querySelector('span > span > div > svg'); + if (svgTick) { + svgTick?.remove(); + } + + const button = option as HTMLElement; + if (button) { + button.style.backgroundColor = 'white'; + button.style.color = 'black'; + button.style.fontWeight = 'normal'; + } + } + }); +}; + export const createNewQueryForDataVolumeInModal = ( selectedInput: string, selectedValue: string @@ -248,3 +283,35 @@ export const createNewQueryForNumberOfEventsInModal = ( return newQuery; }; + +export async function fetchParsedValues(): Promise { + return new Promise((resolve, reject) => { + const searchJob = SearchJob.create( + { + search: `index=_internal source=*license_usage.log type=Usage | fieldsummary | fields field values | where field IN ("s", "st", "idx", "h")`, + }, + { cache: true, cacheLimit: 300 } + ); + + const resultsSubscription = searchJob + .getResults({ + count: 0, + }) + .subscribe({ + next: (response: SearchResponse) => { + try { + resolve(response); + } catch (error) { + reject(error); + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (error: any) => { + reject(error); + }, + complete: () => { + resultsSubscription.unsubscribe(); + }, + }); + }); +} From 07e4252a916a475a871f732292dcf43be743e23c Mon Sep 17 00:00:00 2001 From: rohanm-crest Date: Mon, 30 Sep 2024 12:53:21 +0530 Subject: [PATCH 16/25] feat(dropdown): added custom dropdown in data ingestion modal --- docs/dashboard.md | 4 + splunk_add_on_ucc_framework/dashboard.py | 11 +- .../data_ingestion_modal_definition.json | 17 +- .../data_ingestion_modal_definition.json | 17 +- ui/src/pages/Dashboard/DashboardModal.tsx | 137 +++------------- ui/src/pages/Dashboard/DataIngestion.tsx | 108 ++++++++---- ui/src/pages/Dashboard/DataIngestionModal.tsx | 152 +++++++++++++---- ui/src/pages/Dashboard/dashboardStyle.css | 30 +++- ui/src/pages/Dashboard/utils.tsx | 154 +++++++++++------- 9 files changed, 369 insertions(+), 261 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index efacf4d1a..4cdfdcf50 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -435,3 +435,7 @@ e.g. of globalConfig.json: the above configuration will create the following filter query: `...source=*license_usage.log type=Usage (st IN ("*addon123*","my_custom_condition*"))...` + +> Note: + +> - In the Data Ingestion table, the first column displays the `View by` options list. When you click on any row in this column, a modal opens, showing detailed information such as `Data volume` and the `Number of events` over time, visualized in charts. The modal allows you to adjust the options via a dropdown to view data for different View by options. This enables dynamic exploration of data trends for various selected inputs. \ No newline at end of file diff --git a/splunk_add_on_ucc_framework/dashboard.py b/splunk_add_on_ucc_framework/dashboard.py index 334802e08..083b34063 100644 --- a/splunk_add_on_ucc_framework/dashboard.py +++ b/splunk_add_on_ucc_framework/dashboard.py @@ -247,7 +247,16 @@ def generate_dashboard_content( definition_json_name == default_definition_json_filename["data_ingestion_modal_definition"] ): - content = utils.get_j2_env().get_template(definition_json_name).render() + content = ( + utils.get_j2_env() + .get_template(definition_json_name) + .render( + data_ingestion=data_ingestion.format( + lic_usg_condition=lic_usg_condition, determine_by=determine_by + ), + events_count=events_count.format(addon_name=addon_name.lower()), + ) + ) return content diff --git a/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json b/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json index ee68bda4b..9a4ed5fb8 100644 --- a/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json +++ b/splunk_add_on_ucc_framework/templates/data_ingestion_modal_definition.json @@ -75,7 +75,7 @@ "data_ingestion_modal_data_volume_ds": { "type": "ds.search", "options": { - "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", + "query": "{{data_ingestion}}", "queryParameters": { "earliest": "$data_ingestion_modal_time.earliest$", "latest": "$data_ingestion_modal_time.latest$" @@ -85,7 +85,7 @@ "ds_search_1": { "type": "ds.search", "options": { - "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", + "query": "{{events_count}}", "queryParameters": { "earliest": "$data_ingestion_modal_time.earliest$", "latest": "$data_ingestion_modal_time.latest$" @@ -96,14 +96,6 @@ }, "defaults": {}, "inputs": { - "data_ingestion_modal_dynamic_input": { - "type": "input.dropdown", - "options": { - "items": [], - "token": "data_ingestion_view_by" - }, - "title": "Input" - }, "data_ingestion_modal_time_window": { "options": { "defaultValue": "-24h,now", @@ -115,10 +107,7 @@ }, "layout": { "type": "grid", - "globalInputs": [ - "data_ingestion_modal_dynamic_input", - "data_ingestion_modal_time_window" - ], + "globalInputs": ["data_ingestion_modal_time_window"], "structure": [ { "item": "data_ingestion_modal_timerange_label_start_viz", diff --git a/tests/unit/expected_results/data_ingestion_modal_definition.json b/tests/unit/expected_results/data_ingestion_modal_definition.json index ee68bda4b..9a4ed5fb8 100644 --- a/tests/unit/expected_results/data_ingestion_modal_definition.json +++ b/tests/unit/expected_results/data_ingestion_modal_definition.json @@ -75,7 +75,7 @@ "data_ingestion_modal_data_volume_ds": { "type": "ds.search", "options": { - "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", + "query": "{{data_ingestion}}", "queryParameters": { "earliest": "$data_ingestion_modal_time.earliest$", "latest": "$data_ingestion_modal_time.latest$" @@ -85,7 +85,7 @@ "ds_search_1": { "type": "ds.search", "options": { - "query": "| inputlookup firewall_example.csv \n| search host=host8 OR host=host18\n| eval host=case(host=\"host8\", \"spend\", host=\"host18\", \"score\", 1=1, host)\n| eval myTime=strftime(timestamp,\"%H:%M\") \n| chart count over myTime by host\n| eval score=score*2\n| eval spend=spend*1500\n| fields myTime spend score\n| rename score as \"Security Score\", spend as \"Security Spend\"", + "query": "{{events_count}}", "queryParameters": { "earliest": "$data_ingestion_modal_time.earliest$", "latest": "$data_ingestion_modal_time.latest$" @@ -96,14 +96,6 @@ }, "defaults": {}, "inputs": { - "data_ingestion_modal_dynamic_input": { - "type": "input.dropdown", - "options": { - "items": [], - "token": "data_ingestion_view_by" - }, - "title": "Input" - }, "data_ingestion_modal_time_window": { "options": { "defaultValue": "-24h,now", @@ -115,10 +107,7 @@ }, "layout": { "type": "grid", - "globalInputs": [ - "data_ingestion_modal_dynamic_input", - "data_ingestion_modal_time_window" - ], + "globalInputs": ["data_ingestion_modal_time_window"], "structure": [ { "item": "data_ingestion_modal_timerange_label_start_viz", diff --git a/ui/src/pages/Dashboard/DashboardModal.tsx b/ui/src/pages/Dashboard/DashboardModal.tsx index 7e7640bdd..d0c29c200 100644 --- a/ui/src/pages/Dashboard/DashboardModal.tsx +++ b/ui/src/pages/Dashboard/DashboardModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback, useRef, useMemo, useState } from 'react'; +import React, { useEffect, useCallback, useRef } from 'react'; import { DashboardCore } from '@splunk/dashboard-core'; import { DashboardContextProvider } from '@splunk/dashboard-context'; import EnterpriseViewOnlyPreset from '@splunk/dashboard-presets/EnterpriseViewOnlyPreset'; @@ -8,143 +8,60 @@ import { createNewQueryForDataVolumeInModal, createNewQueryForNumberOfEventsInModal, getActionButtons, - setandRemoveOptionsFromDropdown, } from './utils'; /** * @param {object} props * @param {object} props.dashboardDefinition custom dashboard definition - * @param {string} props.selectedLabelForInput state for display title in the modal - * @param {object} props.setDisplayModalForInput setstate for header value of modal + * @param {string} props.selectValueForDropdownInModal state for value in the modal + * @param {string} props.selectTitleForDropdownInModal state for title in the modal */ export const DashboardModal = ({ dashboardDefinition, - selectedLabelForInput, - setDisplayModalForInput, + selectValueForDropdownInModal, + selectTitleForDropdownInModal, }: { dashboardDefinition: Record | null; - selectedLabelForInput: string; - setDisplayModalForInput: React.Dispatch>; + selectValueForDropdownInModal: string; + selectTitleForDropdownInModal: string; }) => { const dashboardCoreApi = useRef(null); const setDashboardCoreApi = useCallback((api: DashboardCoreApi | null) => { dashboardCoreApi.current = api; }, []); - const [inputSelectorValue, setInputSelectorValue] = useState(''); - const updateModalData = useCallback( - (selectedValue: string) => { - const copyDashboardDefinition = JSON.parse(JSON.stringify(dashboardDefinition)); - const selectedInput = - copyDashboardDefinition.inputs?.data_ingestion_modal_dynamic_input?.title || ''; - const dataVolumeQuery = createNewQueryForDataVolumeInModal( - selectedInput, - selectedValue - ); - copyDashboardDefinition.inputs.data_ingestion_modal_dynamic_input.options.defaultValue = - selectedValue; - const eventsQuery = createNewQueryForNumberOfEventsInModal( - selectedInput, - selectedValue - ); + const updateModalData = useCallback(() => { + const copyDashboardDefinition = JSON.parse(JSON.stringify(dashboardDefinition)); - copyDashboardDefinition.dataSources.data_ingestion_modal_data_volume_ds.options.query = - dataVolumeQuery; - copyDashboardDefinition.dataSources.ds_search_1.options.query = eventsQuery; - copyDashboardDefinition.inputs.data_ingestion_modal_dynamic_input.options.selectFirstSearchResult = - false; - return copyDashboardDefinition; - }, - [dashboardDefinition] - ); + const eventsQuery = createNewQueryForNumberOfEventsInModal( + selectTitleForDropdownInModal, + selectValueForDropdownInModal, + copyDashboardDefinition.dataSources.ds_search_1.options.query + ); + const dataVolumeQuery = createNewQueryForDataVolumeInModal( + selectTitleForDropdownInModal, + selectValueForDropdownInModal, + copyDashboardDefinition.dataSources.data_ingestion_modal_data_volume_ds.options.query + ); - useEffect(() => { - let observer: MutationObserver | null = null; - - const setupObserver = (targetNode: Element) => { - const config = { attributes: true }; - - const callback = (mutationsList: MutationRecord[]) => { - mutationsList.forEach((mutation: MutationRecord) => { - if (mutation.attributeName === 'aria-expanded') { - setandRemoveOptionsFromDropdown( - mutation.target as Element, - selectedLabelForInput - ); - } - }); - }; - - observer = new MutationObserver(callback); - observer.observe(targetNode, config); - }; - - const findTargetNode = () => { - const targetNode = document.querySelector( - '[data-input-id="data_ingestion_modal_dynamic_input"] button' - ); - - if (targetNode) { - setupObserver(targetNode); - const innerSpan = targetNode.querySelector('span > span'); - if (innerSpan) { - innerSpan.textContent = inputSelectorValue || selectedLabelForInput; - } - } else { - // Retry if the targetNode is not yet available - requestAnimationFrame(findTargetNode); - } - }; + copyDashboardDefinition.dataSources.data_ingestion_modal_data_volume_ds.options.query = + dataVolumeQuery; + copyDashboardDefinition.dataSources.ds_search_1.options.query = eventsQuery; - // Start finding the node after the component mounts - requestAnimationFrame(findTargetNode); - - // Cleanup observer on component unmount - return () => { - if (observer) { - observer.disconnect(); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inputSelectorValue]); + return copyDashboardDefinition; + }, [dashboardDefinition, selectTitleForDropdownInModal, selectValueForDropdownInModal]); // Update the dashboard when the definition or selected input changes useEffect(() => { if (dashboardCoreApi.current && dashboardDefinition) { - const updatedModalData = updateModalData(selectedLabelForInput); + const updatedModalData = updateModalData(); dashboardCoreApi.current.updateDefinition(updatedModalData); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dashboardDefinition]); - - // Event handler for input changes in the modal - const handleDashboardEvent = useCallback( - (event) => { - if ( - event.targetId === 'data_ingestion_modal_dynamic_input' && - event.type === 'input.change' && - dashboardCoreApi.current - ) { - setInputSelectorValue(event.payload.value); - setDisplayModalForInput(event.payload.value); - const updatedModalData = updateModalData(event.payload.value); - dashboardCoreApi.current.updateDefinition(updatedModalData); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [dashboardDefinition] - ); - - const dashboardPlugin = useMemo( - () => ({ onEventTrigger: handleDashboardEvent }), - [handleDashboardEvent] - ); + }, [dashboardDefinition, selectValueForDropdownInModal]); return dashboardDefinition ? ( - + | null>(null); - const [displayModalForInput, setDisplayModalForInput] = useState(null); + const [selectValueForDropdownInModal, setSelectValueForDropdownInModal] = useState< + string | null + >(null); + const [selectTitleForDropdownInModal, setSelectTitleForDropdownInModal] = useState< + string | null + >(null); + const [dataIngestionDropdownValues, setDataIngestionDropdownValues] = useState([{}]); useEffect(() => { makeVisualAdjustmentsOnDataIngestionPage(); @@ -147,33 +153,76 @@ export const DataIngestionDashboard = ({ ); const modalInputSelectorName = event.payload.data.fields[0].name; const values = await fetchParsedValues(); - let extractColumnsValues: string[] = []; - values.results.forEach((value) => { - if (queryMap[modalInputSelectorName] === value.field) { - const dropDownValues = JSON.parse(value.values); - extractColumnsValues = dropDownValues.map((item: FieldValue) => item.value); + let extractColumnsValues: Record[] = []; + const processResults = (results: Record[], fieldKey: string) => { + results.forEach((value) => { + if (queryMap[fieldKey] === value.field) { + const dropDownValues = JSON.parse(value.values); + extractColumnsValues = dropDownValues.map((item: FieldValue) => ({ + label: item.value, + value: item.value, + })); + } + }); + }; + const mergeInputValues = ( + activeValues?: string[], + inactiveValues?: string[] | string + ): Record[] => { + // Handle inactiveValues being either a string or an array of strings + let safeInactiveValues: Record[] = []; + if (typeof inactiveValues === 'string') { + safeInactiveValues = [ + { label: `${inactiveValues} (disabled)`, value: inactiveValues }, + ]; // Convert single string to array + } else if (Array.isArray(inactiveValues)) { + safeInactiveValues = inactiveValues.map((item: string) => ({ + label: `${item} (disabled)`, + value: item, + })); } - }); - - setDisplayModalForInput(event.payload.value); - const columnsArray: { label: string; value: string }[] = extractColumnsValues.map( - (item: string) => ({ - label: item, - value: item, - }) - ); - // update the input selector name and value in the modal - copyDataIngestionModalJson.inputs.data_ingestion_modal_dynamic_input.title = - modalInputSelectorName; - copyDataIngestionModalJson.inputs.data_ingestion_modal_dynamic_input.options.items = - columnsArray; + // Handle activeValues being either a string or an array of strings + let safeActiveValues: Record[] = []; + if (typeof activeValues === 'string') { + safeActiveValues = [{ label: activeValues, value: activeValues }]; // Convert single string to array + } else if (Array.isArray(activeValues)) { + safeActiveValues = activeValues.map((item: string) => ({ + label: item, + value: item, + })); + } + + // Merge active and inactive inputs (safe arrays) + const mergedValues = [...safeActiveValues, ...safeInactiveValues]; + return mergedValues; + }; - // Modify visualizations only for specific cases if (modalInputSelectorName === 'Input') { - // Remove data volume visualization for "Input" + const activeState = values[0]?.results[0]?.Active; + const activeInputs = values[0]?.results[0]?.event_input; + const inactiveInputs = values[0]?.results[1]?.event_input; + + // Handle cases where only active or inactive inputs exist + if (activeState === 'yes') { + extractColumnsValues = mergeInputValues(activeInputs, inactiveInputs); + } else if (activeState === 'no') { + extractColumnsValues = mergeInputValues(inactiveInputs, activeInputs); + } + } else if (modalInputSelectorName === 'Account') { + processResults(values[1].results, modalInputSelectorName); + } else { + processResults(values[2].results, modalInputSelectorName); + } + setDataIngestionDropdownValues(extractColumnsValues); + setSelectTitleForDropdownInModal(modalInputSelectorName); + + // Modify visualizations only for specific cases + if (modalInputSelectorName === 'Input' || modalInputSelectorName === 'Account') { + // Remove data volume visualization for "Input" and "Account" delete copyDataIngestionModalJson.visualizations .data_ingestion_modal_data_volume_viz; + copyDataIngestionModalJson.layout.structure[3].position.y = 80; } else if (modalInputSelectorName === 'Host') { // Remove event count visualization for "Host" delete copyDataIngestionModalJson.visualizations @@ -187,7 +236,7 @@ export const DataIngestionDashboard = ({ event.payload.cellIndex === 0 && event.payload.value ) { - setDisplayModalForInput(event.payload.value); + setSelectValueForDropdownInModal(event.payload.value); } }, [dataIngestionModalDef] @@ -206,10 +255,13 @@ export const DataIngestionDashboard = ({ > <> setDisplayModalForInput(null)} - title={displayModalForInput || ''} + open={!!selectValueForDropdownInModal} + handleRequestClose={() => setSelectValueForDropdownInModal(null)} + title={selectTitleForDropdownInModal || ''} acceptBtnLabel="Done" + dataIngestionDropdownValues={dataIngestionDropdownValues} + selectValueForDropdownInModal={selectValueForDropdownInModal || ''} + setSelectValueForDropdownInModal={setSelectValueForDropdownInModal} > diff --git a/ui/src/pages/Dashboard/DataIngestionModal.tsx b/ui/src/pages/Dashboard/DataIngestionModal.tsx index 734a1bb1b..a509d7475 100644 --- a/ui/src/pages/Dashboard/DataIngestionModal.tsx +++ b/ui/src/pages/Dashboard/DataIngestionModal.tsx @@ -1,15 +1,20 @@ import Modal from '@splunk/react-ui/Modal'; -import React, { ReactElement } from 'react'; +import React, { ReactElement, useEffect } from 'react'; import styled from 'styled-components'; import { variables } from '@splunk/themes'; import Button from '@splunk/react-ui/Button'; +import Dropdown from '@splunk/react-ui/Dropdown'; +import Menu from '@splunk/react-ui/Menu'; +import Checkmark from '@splunk/react-icons/Checkmark'; import { StyledButton } from '../EntryPageStyle'; +import { makeVisualAdjustmentsOnDataIngestionModal } from './utils'; const ModalWrapper = styled(Modal)` width: 60vw; height: 80vh; margin-top: 3vh; + height: 80vh; `; const ModalHeader = styled(Modal.Header)` @@ -22,6 +27,8 @@ const ModalFooter = styled(Modal.Footer)` const ModalBody = styled(Modal.Body)` background-color: ${variables.neutral200}; + padding: 15px 30px; + height: 70vh; `; const FooterButtonGroup = styled('div')` @@ -43,45 +50,118 @@ export const DataIngestionModal = ({ handleRequestClose, title, acceptBtnLabel = 'Done', + dataIngestionDropdownValues, + selectValueForDropdownInModal, + setSelectValueForDropdownInModal, children, }: { open?: boolean; handleRequestClose: () => void; title?: string; acceptBtnLabel?: string; + dataIngestionDropdownValues: Record[]; + selectValueForDropdownInModal: string; + setSelectValueForDropdownInModal: React.Dispatch>; children: ReactElement; -}) => ( - - - {children} - - -