mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 16:14:42 +00:00
feat/interactive dashbaord v2 (#9011)
* feat: add drilldown options in uplot * feat: add time range to timeseries, bar charts * feat: remove unwanted code * feat: minor refactor * feat: drilldown prop drilldowned * feat: refactor code * feat: update click plugin in uplot * feat: lint fix * feat: add search to breakout and other refactor * feat: context menu - increase width and add overlay * feat: add context links * feat: context links init * feat: context links init * feat: context links init * feat: update context link modal form init * feat: add double way sync on urls and param * feat: minor refactor * feat: minor refactor * feat: change contextlinks data structure * feat: context menu changes init * feat: context menu hook refactor * feat: context links processors * feat: context variables hook added * feat: add support for field variables * feat: minor refactor * feat: minor refactor * feat: minor refactor * feat: handle on save * feat: minor refactor * feat: snapshot update * feat: revert qbv5 * feat: aggregation header val * feat: fix header color * feat: minor refactor * feat: minor refactor * feat: fix breaking changes from qb v5 * feat: change api for breakout opitons * feat: minor refactor * feat: minor refactor * fix: added fix for extractquerypararms when value is string in multivalue operator * feat: minor refactor * feat: add back in breakout * feat: minor refactor * feat: add substitute var api call to decode vars * feat: minor fix * feat: optimize query value comparison in QueryBuilderV2 * feat: minor fix * feat: minor fix * feat: test fix * feat: added dynamic variables creation flow (#7541) * feat: added dynamic variables creation flow * feat: added keys and value apis and hooks * feat: added api and select component changes * feat: added keys fetching and preview values * feat: added dynamic variable to variable items * feat: handled value persistence and tab switches * feat: added default value and formed a schema for dyn-variables * feat: added client and server side searches * feat: corrected the initial load getfieldKey api * feat: removed fetch on mount restriction * feat: added dynamic variable to the dashboard details (#7755) * feat: added dynamic variable to the dashboard details * feat: added new component to existing variables * feat: added enhancement to multiselect and select for dyn-variables * feat: added refetch method between all dynamic-variables * feat: correct error handling * feat: correct error handling * feat: enforced non-empty selectedvalues and default value * feat: added client and server side searches * feat: retry on error * feat: correct error handling * feat: handle defautl value in existing variables * feat: lowercase the source for payload * feat: fixed the incorrect assignment of active indices * feat: improved handling of all option * feat: improved the ALL option visuals * feat: handled default value enforcement in existing variables * feat: added unix time to values call * feat: added incomplete data message and info to search * feat: changed dashboard panel call handling with existing variables * feat: adjusted the response type and data with the new API schema for values * feat: code refactor * feat: made dyn-variable option as the default * feat: added test cases for dyn variable creation and completion * feat: updated test cases * feat: added variable in url and made dashboard sync around that and sharable (#7944) * feat: added dynamic variable to the dashboard details * feat: added new component to existing variables * feat: added enhancement to multiselect and select for dyn-variables * feat: added refetch method between all dynamic-variables * feat: correct error handling * feat: correct error handling * feat: enforced non-empty selectedvalues and default value * feat: added client and server side searches * feat: retry on error * feat: correct error handling * feat: handle defautl value in existing variables * feat: lowercase the source for payload * feat: fixed the incorrect assignment of active indices * feat: improved handling of all option * feat: improved the ALL option visuals * feat: handled default value enforcement in existing variables * feat: added unix time to values call * feat: added incomplete data message and info to search * feat: changed dashboard panel call handling with existing variables * feat: adjusted the response type and data with the new API schema for values * feat: code refactor * feat: made dyn-variable option as the default * feat: added test cases for dyn variable creation and completion * feat: updated test cases * feat: added variable in url and made dashboard sync around that and sharable * feat: added test cases * feat: added safety check * feat: enabled url setting on first load itself * feat: code refactor * feat: cleared options query param when on dashboard list page * feat: resolved conflicts * feat: added dynamic variable suggestion in where clause * feat: added test cases for hooks and api call functions * feat: added test case for querybuildersearchv2 suggestion changes * feat: code refactor * feat: updated test case * feat: corrected the regex matcher for resolved titles * feat: added ability to add/remove variable filter to one or more existing panels * feat: added widgetselector on variable creation * feat: show labels in widget selector * feat: added apply to all and variable removal logical * feat: refectch only related and affected panels in case of dynamic variables * feat: added button loader for apply-all * feat: light-mode styles * feat: minor refactor * feat: added test cases * feat: refactor * feat: remove consoles * feat: pass panel types to substitutevars * feat: cross filtering init * fix: added fix for query builder filters * feat: cross filtering add set/unset/create functionality * feat: test update * fix: added migration to filter expression for crud operations of variable * feat: format legend name according to existing format * feat: breakout test init * feat: breakout test match query * feat: context links tests * feat: minor refactor * feat: show edit only if user has access * feat: added dynamic variables creation flow (#7541) * feat: added dynamic variables creation flow * feat: added keys and value apis and hooks * feat: added api and select component changes * feat: added keys fetching and preview values * feat: added dynamic variable to variable items * feat: handled value persistence and tab switches * feat: added default value and formed a schema for dyn-variables * feat: added client and server side searches * feat: corrected the initial load getfieldKey api * feat: removed fetch on mount restriction * feat: added dynamic variable to the dashboard details (#7755) * feat: added dynamic variable to the dashboard details * feat: added new component to existing variables * feat: added enhancement to multiselect and select for dyn-variables * feat: added refetch method between all dynamic-variables * feat: correct error handling * feat: correct error handling * feat: enforced non-empty selectedvalues and default value * feat: added client and server side searches * feat: retry on error * feat: correct error handling * feat: handle defautl value in existing variables * feat: lowercase the source for payload * feat: fixed the incorrect assignment of active indices * feat: improved handling of all option * feat: improved the ALL option visuals * feat: handled default value enforcement in existing variables * feat: added unix time to values call * feat: added incomplete data message and info to search * feat: changed dashboard panel call handling with existing variables * feat: adjusted the response type and data with the new API schema for values * feat: code refactor * feat: made dyn-variable option as the default * feat: added test cases for dyn variable creation and completion * feat: updated test cases * feat: added dynamic variable suggestion in where clause * feat: added test cases for hooks and api call functions * feat: added test case for querybuildersearchv2 suggestion changes * feat: code refactor * feat: updated test case * feat: corrected the regex matcher for resolved titles * feat: added ability to add/remove variable filter to one or more existing panels * feat: added widgetselector on variable creation * feat: show labels in widget selector * feat: added apply to all and variable removal logical * feat: refectch only related and affected panels in case of dynamic variables * feat: added button loader for apply-all * feat: light-mode styles * fix: added migration to filter expression for crud operations of variable * feat: reverted dynamic variable url config changes (#8877) * Revert "feat: changed query param name" This reverts commit 62bee5f003bf74b0da1c5951f1b5d0f2c250905d. * Revert "feat: added user-friendly format to dashboard variable url" This reverts commit 6de8b1c2e8c6a838941014ea4929e9f5c908d975. * feat: reverted url var changes * feat: reverted url changed from usedashboardvarupdate hook * feat: send empty array for widgetId * feat: added type in the variables in query_range payload for dynamic * feat: minor fixes * fix: added fix for multivalue operator without brackets * feat: minor fix * feat: fix failing test * feat: change revert * test: added tests for querycontextUtils + querybuilderv2 utils * fix: added fix for replacing filter with the new value * fix: added fix for replacing filters + datetimepicker composite query * test: fixed querybuilderv2 utils test * feat: handle number dataType in filters * feat: correct the variable addition to panel format for new qb expression * feat: remove other queries in breakout * feat: add metric to traces mapping * feat: pass proper time range * feat: update time range logic * feat: value panel drilldown init * feat: value panel drilldown init * feat: enable context links in value panel * feat: minor fix * feat: update snapshot * feat: hide breakout in value panel * feat: add panel type to view mode * feat: add support to change panel in breakouts * feat: panel change for breakout logic added * chore: fix style * chore: show variables suggestion while creating context links * chore: add timestamp to graphs * chore: add timestamp to table panel * chore: fix failing tests * chore: fix infinite re-rendering due to queryRange * chore: send appropriate time range when signal is metrics * chore: show variables suggestion while creating context links * chore: minor refactor * chore: show trace details link if filter has trace_id * chore: fix infinite render of table component * chore: added tests for v2 * fix: context links set from dropdown * chore: minor refactor * chore: minor refactor * chore: fix test * chore: fix timerange for apm metrics * fix: get correct timestamp for clicked data * chore: comment out change to histogram on breakout by number * chore: change panel type on panel type change in url * chore: remove consoles * feat: added dynamic variables creation flow (#7541) * feat: added dynamic variables creation flow * feat: added keys and value apis and hooks * feat: added api and select component changes * feat: added keys fetching and preview values * feat: added dynamic variable to variable items * feat: handled value persistence and tab switches * feat: added default value and formed a schema for dyn-variables * feat: added client and server side searches * feat: corrected the initial load getfieldKey api * feat: removed fetch on mount restriction * feat: added dynamic variable to the dashboard details (#7755) * feat: added dynamic variable to the dashboard details * feat: added new component to existing variables * feat: added enhancement to multiselect and select for dyn-variables * feat: added refetch method between all dynamic-variables * feat: correct error handling * feat: correct error handling * feat: enforced non-empty selectedvalues and default value * feat: added client and server side searches * feat: retry on error * feat: correct error handling * feat: handle defautl value in existing variables * feat: lowercase the source for payload * feat: fixed the incorrect assignment of active indices * feat: improved handling of all option * feat: improved the ALL option visuals * feat: handled default value enforcement in existing variables * feat: added unix time to values call * feat: added incomplete data message and info to search * feat: changed dashboard panel call handling with existing variables * feat: adjusted the response type and data with the new API schema for values * feat: code refactor * feat: made dyn-variable option as the default * feat: added test cases for dyn variable creation and completion * feat: updated test cases * feat: fix lint and test cases * feat: fix typo * feat: fixed test case * feat: added dynamic variable suggestion in where clause * feat: added test cases for hooks and api call functions * feat: added test case for querybuildersearchv2 suggestion changes * feat: code refactor * feat: corrected the regex matcher for resolved titles * feat: fixed test cases * feat: added ability to add/remove variable filter to one or more existing panels * feat: added widgetselector on variable creation * feat: show labels in widget selector * feat: added apply to all and variable removal logical * feat: refectch only related and affected panels in case of dynamic variables * feat: added button loader for apply-all * feat: light-mode styles * fix: added migration to filter expression for crud operations of variable * feat: added type in the variables in query_range payload for dynamic * feat: correct the variable addition to panel format for new qb expression * feat: added test cases for dynamic variable and add/remove panel feat * feat: implemented where clause suggestion in new qb v5 * feat: added retries for dyn variable and fixed on-enter selection issue * feat: added relatedValues and existing query in param related changes * feat: sanitized data storage and removed duplicates * fix: fixed typechecks * feat: updated panel wait and refetch logic and ALL option selection * feat: fixed variable tabel reordering issue * feat: added empty name validation in variable creation * feat: change value to searchtext in values API * feat: added option for regex in the component, disabled for now * feat: added beta and not rec. tag in variable tabs * feat: added check to prevent api and updates calls with same payload * feat: optimized localstorage for all selection in dynamic variable and updated __all__ case * feat: resolved variable tables infinite loop update error * feat: aded variable name auto-update based on attribute name entered for dynamic variables * feat: modified only/all click behaviour and set all selection always true for dynamic variable * feat: fix dropdown closing doesn't reset us back to our all available values when we have a search * feat: handled all state distinction and carry forward in existing variables * feat: trucate + n more tooltip content to 10 * feat: fixed infinite loop because of dependency of frequently changing object ref in var table * feat: fixed inconsist search implementations * feat: reverted only - all updated area implementation * feat: added more space for search in multiselect component * feat: checked for variable id instead of variable key for refetch * feat: improved performance around multiselect component and added confirm modal for apply to all * feat: rewrite functionality around add and remove panels * feat: changed color for apply to all modal * feat: added changes under flag to handle variable specific removal for removeKeysFromExpression func * feat: added validation in variable edit panel * chore: fix dynamic variable update in context menu to latest logic * chore: minor fix * chore: type fix * fix: remove unwanted code * fix: remove unwanted code * fix: resolved pr comments * fix: minor fix * fix: fix tests * fix: style fix --------- Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local> Co-authored-by: Abhi Kumar <ahrefabhi@gmail.com> Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Co-authored-by: SagarRajput-7 <sagar@signoz.io>
This commit is contained in:
parent
7f925bd50e
commit
eee96503ff
@ -169,6 +169,7 @@
|
|||||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-drawer-close {
|
.ant-drawer-close {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export enum QueryParams {
|
|||||||
msgSystem = 'msgSystem',
|
msgSystem = 'msgSystem',
|
||||||
destination = 'destination',
|
destination = 'destination',
|
||||||
kindString = 'kindString',
|
kindString = 'kindString',
|
||||||
|
summaryFilters = 'summaryFilters',
|
||||||
tab = 'tab',
|
tab = 'tab',
|
||||||
thresholds = 'thresholds',
|
thresholds = 'thresholds',
|
||||||
selectedExplorerView = 'selectedExplorerView',
|
selectedExplorerView = 'selectedExplorerView',
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
.panel-type-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 16px;
|
||||||
|
min-width: 180px;
|
||||||
|
|
||||||
|
.typography {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bg-slate-600);
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-type-select {
|
||||||
|
.view-panel-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-item-option-content {
|
||||||
|
.view-panel-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import './PanelTypeSelector.scss';
|
||||||
|
|
||||||
|
import { Select, Typography } from 'antd';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import GraphTypes from 'container/NewDashboard/ComponentsSlider/menuItems';
|
||||||
|
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface PanelTypeSelectorProps {
|
||||||
|
selectedPanelType: PANEL_TYPES;
|
||||||
|
disabled?: boolean;
|
||||||
|
query: Query;
|
||||||
|
widgetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelTypeSelector({
|
||||||
|
selectedPanelType,
|
||||||
|
disabled = false,
|
||||||
|
query,
|
||||||
|
widgetId,
|
||||||
|
}: PanelTypeSelectorProps): JSX.Element {
|
||||||
|
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(newPanelType: PANEL_TYPES): void => {
|
||||||
|
// Transform the query for the new panel type using handleQueryChange
|
||||||
|
const transformedQuery = handleQueryChange(
|
||||||
|
newPanelType as any,
|
||||||
|
query,
|
||||||
|
selectedPanelType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use redirectWithQueryBuilderData to update URL with transformed query and new panel type
|
||||||
|
redirectWithQueryBuilderData(
|
||||||
|
transformedQuery,
|
||||||
|
{
|
||||||
|
[QueryParams.expandedWidgetId]: widgetId,
|
||||||
|
[QueryParams.graphType]: newPanelType,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[redirectWithQueryBuilderData, query, selectedPanelType, widgetId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel-type-selector">
|
||||||
|
<Select
|
||||||
|
onChange={handleChange}
|
||||||
|
value={selectedPanelType}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
className="panel-type-select"
|
||||||
|
data-testid="panel-change-select"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{GraphTypes.map((item) => (
|
||||||
|
<Option key={item.name} value={item.name}>
|
||||||
|
<div className="view-panel-select-option">
|
||||||
|
<div className="icon">{item.icon}</div>
|
||||||
|
<Typography.Text className="display">{item.display}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelTypeSelector.defaultProps = {
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PanelTypeSelector;
|
||||||
@ -4,7 +4,9 @@
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|
||||||
.full-view-header-container {
|
.full-view-header-container {
|
||||||
height: 40px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-container {
|
.graph-container {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import './WidgetFullView.styles.scss';
|
import './WidgetFullView.styles.scss';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -8,36 +9,47 @@ import {
|
|||||||
import { Button, Input, Spin } from 'antd';
|
import { Button, Input, Spin } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { ToggleGraphProps } from 'components/Graph/types';
|
import { ToggleGraphProps } from 'components/Graph/types';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import TimePreference from 'components/TimePreferenceDropDown';
|
import TimePreference from 'components/TimePreferenceDropDown';
|
||||||
|
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
|
||||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||||
import {
|
import {
|
||||||
timeItems,
|
timeItems,
|
||||||
timePreferance,
|
timePreferance,
|
||||||
} from 'container/NewWidget/RightContainer/timeItems';
|
} from 'container/NewWidget/RightContainer/timeItems';
|
||||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||||
|
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useChartMutable } from 'hooks/useChartMutable';
|
import { useChartMutable } from 'hooks/useChartMutable';
|
||||||
|
import useComponentPermission from 'hooks/useComponentPermission';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
import GetMinMax from 'lib/getMinMax';
|
import GetMinMax from 'lib/getMinMax';
|
||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
import { useAppContext } from 'providers/App/App';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { UpdateTimeInterval } from 'store/actions';
|
import { UpdateTimeInterval } from 'store/actions';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
import { Warning } from 'types/api';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { getGraphType } from 'utils/getGraphType';
|
import { getGraphType } from 'utils/getGraphType';
|
||||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||||
|
|
||||||
import { getLocalStorageGraphVisibilityState } from '../utils';
|
import { getLocalStorageGraphVisibilityState } from '../utils';
|
||||||
import { PANEL_TYPES_VS_FULL_VIEW_TABLE } from './contants';
|
import { PANEL_TYPES_VS_FULL_VIEW_TABLE } from './contants';
|
||||||
|
import PanelTypeSelector from './PanelTypeSelector';
|
||||||
import { GraphContainer, TimeContainer } from './styles';
|
import { GraphContainer, TimeContainer } from './styles';
|
||||||
import { FullViewProps } from './types';
|
import { FullViewProps } from './types';
|
||||||
|
|
||||||
@ -52,6 +64,7 @@ function FullView({
|
|||||||
onClickHandler,
|
onClickHandler,
|
||||||
customOnDragSelect,
|
customOnDragSelect,
|
||||||
setCurrentGraphRef,
|
setCurrentGraphRef,
|
||||||
|
enableDrillDown = false,
|
||||||
}: FullViewProps): JSX.Element {
|
}: FullViewProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const { selectedTime: globalSelectedTime } = useSelector<
|
const { selectedTime: globalSelectedTime } = useSelector<
|
||||||
@ -63,12 +76,16 @@ function FullView({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { handleRunQuery } = useQueryBuilder();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentGraphRef(fullViewRef);
|
setCurrentGraphRef(fullViewRef);
|
||||||
}, [setCurrentGraphRef]);
|
}, [setCurrentGraphRef]);
|
||||||
|
|
||||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||||
|
const { user } = useAppContext();
|
||||||
|
|
||||||
|
const [editWidget] = useComponentPermission(['edit_widget'], user.role);
|
||||||
|
|
||||||
const getSelectedTime = useCallback(
|
const getSelectedTime = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@ -85,17 +102,26 @@ function FullView({
|
|||||||
|
|
||||||
const updatedQuery = widget?.query;
|
const updatedQuery = widget?.query;
|
||||||
|
|
||||||
|
// Panel type derived from URL with fallback to widget setting
|
||||||
|
const selectedPanelType = useMemo(() => {
|
||||||
|
const urlPanelType = urlQuery.get(QueryParams.graphType) as PANEL_TYPES;
|
||||||
|
if (urlPanelType && Object.values(PANEL_TYPES).includes(urlPanelType)) {
|
||||||
|
return urlPanelType;
|
||||||
|
}
|
||||||
|
return widget?.panelTypes || PANEL_TYPES.TIME_SERIES;
|
||||||
|
}, [urlQuery, widget?.panelTypes]);
|
||||||
|
|
||||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
if (selectedPanelType !== PANEL_TYPES.LIST) {
|
||||||
return {
|
return {
|
||||||
selectedTime: selectedTime.enum,
|
selectedTime: selectedTime.enum,
|
||||||
graphType: getGraphType(widget.panelTypes),
|
graphType: getGraphType(selectedPanelType),
|
||||||
query: updatedQuery,
|
query: updatedQuery,
|
||||||
globalSelectedInterval: globalSelectedTime,
|
globalSelectedInterval: globalSelectedTime,
|
||||||
variables: getDashboardVariables(selectedDashboard?.data.variables),
|
variables: getDashboardVariables(selectedDashboard?.data.variables),
|
||||||
fillGaps: widget.fillSpans,
|
fillGaps: widget.fillSpans,
|
||||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
|
||||||
originalGraphType: widget?.panelTypes,
|
originalGraphType: selectedPanelType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||||
@ -114,6 +140,19 @@ function FullView({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
drilldownQuery,
|
||||||
|
dashboardEditView,
|
||||||
|
handleResetQuery,
|
||||||
|
showResetQuery,
|
||||||
|
} = useDrilldown({
|
||||||
|
enableDrillDown,
|
||||||
|
widget,
|
||||||
|
setRequestData,
|
||||||
|
selectedDashboard,
|
||||||
|
selectedPanelType,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRequestData((prev) => ({
|
setRequestData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -121,12 +160,33 @@ function FullView({
|
|||||||
}));
|
}));
|
||||||
}, [selectedTime]);
|
}, [selectedTime]);
|
||||||
|
|
||||||
|
// Update requestData when panel type changes
|
||||||
|
useEffect(() => {
|
||||||
|
setRequestData((prev) => {
|
||||||
|
if (selectedPanelType !== PANEL_TYPES.LIST) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
graphType: getGraphType(selectedPanelType),
|
||||||
|
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
|
||||||
|
originalGraphType: selectedPanelType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For LIST panels, ensure proper configuration
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
graphType: PANEL_TYPES.LIST,
|
||||||
|
formatForWeb: false,
|
||||||
|
originalGraphType: selectedPanelType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [selectedPanelType]);
|
||||||
|
|
||||||
const response = useGetQueryRange(
|
const response = useGetQueryRange(
|
||||||
requestData,
|
requestData,
|
||||||
// selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION,
|
// selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION,
|
||||||
ENTITY_VERSION_V5,
|
ENTITY_VERSION_V5,
|
||||||
{
|
{
|
||||||
queryKey: [widget?.query, widget?.panelTypes, requestData, version],
|
queryKey: [widget?.query, selectedPanelType, requestData, version],
|
||||||
enabled: !isDependedDataLoaded,
|
enabled: !isDependedDataLoaded,
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
},
|
},
|
||||||
@ -169,18 +229,18 @@ function FullView({
|
|||||||
}, [originalName, response.data?.payload.data.result]);
|
}, [originalName, response.data?.payload.data.result]);
|
||||||
|
|
||||||
const canModifyChart = useChartMutable({
|
const canModifyChart = useChartMutable({
|
||||||
panelType: widget.panelTypes,
|
panelType: selectedPanelType,
|
||||||
panelTypeAndGraphManagerVisibility: PANEL_TYPES_VS_FULL_VIEW_TABLE,
|
panelTypeAndGraphManagerVisibility: PANEL_TYPES_VS_FULL_VIEW_TABLE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
if (response.data && selectedPanelType === PANEL_TYPES.BAR) {
|
||||||
const sortedSeriesData = getSortedSeriesData(
|
const sortedSeriesData = getSortedSeriesData(
|
||||||
response.data?.payload.data.result,
|
response.data?.payload.data.result,
|
||||||
);
|
);
|
||||||
response.data.payload.data.result = sortedSeriesData;
|
response.data.payload.data.result = sortedSeriesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data && widget.panelTypes === PANEL_TYPES.PIE) {
|
if (response.data && selectedPanelType === PANEL_TYPES.PIE) {
|
||||||
const transformedData = populateMultipleResults(response?.data);
|
const transformedData = populateMultipleResults(response?.data);
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
response.data = transformedData;
|
response.data = transformedData;
|
||||||
@ -192,83 +252,139 @@ function FullView({
|
|||||||
});
|
});
|
||||||
}, [graphsVisibilityStates]);
|
}, [graphsVisibilityStates]);
|
||||||
|
|
||||||
const isListView = widget.panelTypes === PANEL_TYPES.LIST;
|
const isListView = selectedPanelType === PANEL_TYPES.LIST;
|
||||||
|
|
||||||
const isTablePanel = widget.panelTypes === PANEL_TYPES.TABLE;
|
const isTablePanel = selectedPanelType === PANEL_TYPES.TABLE;
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
|
|
||||||
if (response.isLoading && widget.panelTypes !== PANEL_TYPES.LIST) {
|
if (response.isLoading && selectedPanelType !== PANEL_TYPES.LIST) {
|
||||||
return <Spinner height="100%" size="large" tip="Loading..." />;
|
return <Spinner height="100%" size="large" tip="Loading..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="full-view-container">
|
<div className="full-view-container">
|
||||||
<div className="full-view-header-container">
|
<OverlayScrollbar>
|
||||||
{fullViewOptions && (
|
<>
|
||||||
<TimeContainer $panelType={widget.panelTypes}>
|
<div className="full-view-header-container">
|
||||||
{response.isFetching && (
|
{fullViewOptions && (
|
||||||
<Spin spinning indicator={<LoadingOutlined spin />} />
|
<TimeContainer $panelType={selectedPanelType}>
|
||||||
|
{enableDrillDown && (
|
||||||
|
<div className="drildown-options-container">
|
||||||
|
{showResetQuery && (
|
||||||
|
<Button type="link" onClick={handleResetQuery}>
|
||||||
|
Reset Query
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{editWidget && (
|
||||||
|
<Button
|
||||||
|
className="switch-edit-btn"
|
||||||
|
disabled={response.isFetching || response.isLoading}
|
||||||
|
onClick={(): void => {
|
||||||
|
if (dashboardEditView) {
|
||||||
|
safeNavigate(dashboardEditView);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Switch to Edit Mode
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PanelTypeSelector
|
||||||
|
selectedPanelType={selectedPanelType}
|
||||||
|
disabled={response.isFetching || response.isLoading}
|
||||||
|
query={drilldownQuery}
|
||||||
|
widgetId={widget?.id || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEmpty(response.data?.warning) && (
|
||||||
|
<WarningPopover warningData={response.data?.warning as Warning} />
|
||||||
|
)}
|
||||||
|
<div className="time-container">
|
||||||
|
{response.isFetching && (
|
||||||
|
<Spin spinning indicator={<LoadingOutlined spin />} />
|
||||||
|
)}
|
||||||
|
<TimePreference
|
||||||
|
selectedTime={selectedTime}
|
||||||
|
setSelectedTime={setSelectedTime}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
response.refetch();
|
||||||
|
}}
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TimeContainer>
|
||||||
)}
|
)}
|
||||||
<TimePreference
|
{enableDrillDown && (
|
||||||
selectedTime={selectedTime}
|
<>
|
||||||
setSelectedTime={setSelectedTime}
|
<QueryBuilderV2
|
||||||
/>
|
panelType={selectedPanelType}
|
||||||
<Button
|
version={selectedDashboard?.data?.version || 'v3'}
|
||||||
style={{
|
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||||
marginLeft: '4px',
|
// filterConfigs={filterConfigs}
|
||||||
}}
|
// queryComponents={queryComponents}
|
||||||
onClick={(): void => {
|
/>
|
||||||
response.refetch();
|
<RightToolbarActions
|
||||||
}}
|
onStageRunQuery={(): void => {
|
||||||
type="primary"
|
handleRunQuery();
|
||||||
icon={<SyncOutlined />}
|
}}
|
||||||
/>
|
/>
|
||||||
</TimeContainer>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx('graph-container', {
|
className={cx('graph-container', {
|
||||||
disabled: isDashboardLocked,
|
disabled: isDashboardLocked,
|
||||||
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
'height-widget':
|
||||||
'full-view-graph-container': isListView || isTablePanel,
|
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||||
})}
|
'full-view-graph-container': isListView,
|
||||||
ref={fullViewRef}
|
})}
|
||||||
>
|
ref={fullViewRef}
|
||||||
<GraphContainer
|
>
|
||||||
style={{
|
<GraphContainer
|
||||||
height: isListView ? '100%' : '90%',
|
style={{
|
||||||
}}
|
height: isListView ? '100%' : '90%',
|
||||||
isGraphLegendToggleAvailable={canModifyChart}
|
|
||||||
>
|
|
||||||
{isTablePanel && (
|
|
||||||
<Input
|
|
||||||
addonBefore={<SearchOutlined size={14} />}
|
|
||||||
className="global-search"
|
|
||||||
placeholder="Search..."
|
|
||||||
allowClear
|
|
||||||
key={widget.id}
|
|
||||||
onChange={(e): void => {
|
|
||||||
setSearchTerm(e.target.value || '');
|
|
||||||
}}
|
}}
|
||||||
/>
|
isGraphLegendToggleAvailable={canModifyChart}
|
||||||
)}
|
>
|
||||||
<PanelWrapper
|
{isTablePanel && (
|
||||||
queryResponse={response}
|
<Input
|
||||||
widget={widget}
|
addonBefore={<SearchOutlined size={14} />}
|
||||||
setRequestData={setRequestData}
|
className="global-search"
|
||||||
isFullViewMode
|
placeholder="Search..."
|
||||||
onToggleModelHandler={onToggleModelHandler}
|
allowClear
|
||||||
setGraphVisibility={setGraphsVisibilityStates}
|
key={widget.id}
|
||||||
graphVisibility={graphsVisibilityStates}
|
onChange={(e): void => {
|
||||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
setSearchTerm(e.target.value || '');
|
||||||
tableProcessedDataRef={tableProcessedDataRef}
|
}}
|
||||||
searchTerm={searchTerm}
|
/>
|
||||||
onClickHandler={onClickHandler}
|
)}
|
||||||
/>
|
<PanelWrapper
|
||||||
</GraphContainer>
|
queryResponse={response}
|
||||||
</div>
|
widget={widget}
|
||||||
|
setRequestData={setRequestData}
|
||||||
|
isFullViewMode
|
||||||
|
onToggleModelHandler={onToggleModelHandler}
|
||||||
|
setGraphVisibility={setGraphsVisibilityStates}
|
||||||
|
graphVisibility={graphsVisibilityStates}
|
||||||
|
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||||
|
tableProcessedDataRef={tableProcessedDataRef}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onClickHandler={onClickHandler}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
|
selectedGraph={selectedPanelType}
|
||||||
|
/>
|
||||||
|
</GraphContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</OverlayScrollbar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export const NotFoundContainer = styled.div`
|
|||||||
export const TimeContainer = styled.div<Props>`
|
export const TimeContainer = styled.div<Props>`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
${({ $panelType }): FlattenSimpleInterpolation =>
|
${({ $panelType }): FlattenSimpleInterpolation =>
|
||||||
$panelType === PANEL_TYPES.TABLE
|
$panelType === PANEL_TYPES.TABLE
|
||||||
@ -25,6 +26,14 @@ export const TimeContainer = styled.div<Props>`
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
`
|
`
|
||||||
: css``}
|
: css``}
|
||||||
|
|
||||||
|
.time-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.drildown-options-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GraphContainer = styled.div<GraphContainerProps>`
|
export const GraphContainer = styled.div<GraphContainerProps>`
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export interface FullViewProps {
|
|||||||
isDependedDataLoaded?: boolean;
|
isDependedDataLoaded?: boolean;
|
||||||
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
||||||
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphManagerProps extends UplotProps {
|
export interface GraphManagerProps extends UplotProps {
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||||
|
|
||||||
|
export interface DrilldownQueryProps {
|
||||||
|
widget: Widgets;
|
||||||
|
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||||
|
enableDrillDown: boolean;
|
||||||
|
selectedDashboard: Dashboard | undefined;
|
||||||
|
selectedPanelType: PANEL_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDrilldownReturn {
|
||||||
|
drilldownQuery: Query;
|
||||||
|
dashboardEditView: string;
|
||||||
|
handleResetQuery: () => void;
|
||||||
|
showResetQuery: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDrilldown = ({
|
||||||
|
enableDrillDown,
|
||||||
|
widget,
|
||||||
|
setRequestData,
|
||||||
|
selectedDashboard,
|
||||||
|
selectedPanelType,
|
||||||
|
}: DrilldownQueryProps): UseDrilldownReturn => {
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
const { redirectWithQueryBuilderData, currentQuery } = useQueryBuilder();
|
||||||
|
const compositeQuery = useGetCompositeQueryParam();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableDrillDown && !!compositeQuery) {
|
||||||
|
setRequestData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
query: compositeQuery,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentQuery, compositeQuery]);
|
||||||
|
|
||||||
|
// update composite query with widget query if composite query is not present in url.
|
||||||
|
// Composite query should be in the url if switch to edit mode is clicked or drilldown happens from dashboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableDrillDown && !isMounted.current) {
|
||||||
|
redirectWithQueryBuilderData(compositeQuery || widget.query);
|
||||||
|
}
|
||||||
|
isMounted.current = true;
|
||||||
|
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
|
||||||
|
|
||||||
|
const dashboardEditView = selectedDashboard?.id
|
||||||
|
? generateExportToDashboardLink({
|
||||||
|
query: currentQuery,
|
||||||
|
panelType: selectedPanelType,
|
||||||
|
dashboardId: selectedDashboard?.id || '',
|
||||||
|
widgetId: widget.id,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const showResetQuery = useMemo(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(widget.query?.builder) !==
|
||||||
|
JSON.stringify(compositeQuery?.builder),
|
||||||
|
[widget.query, compositeQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetQuery = useCallback((): void => {
|
||||||
|
redirectWithQueryBuilderData(
|
||||||
|
widget.query,
|
||||||
|
{
|
||||||
|
[QueryParams.expandedWidgetId]: widget.id,
|
||||||
|
[QueryParams.graphType]: widget.panelTypes,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}, [redirectWithQueryBuilderData, widget.query, widget.id, widget.panelTypes]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drilldownQuery: compositeQuery || widget.query,
|
||||||
|
dashboardEditView,
|
||||||
|
handleResetQuery,
|
||||||
|
showResetQuery,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDrilldown;
|
||||||
@ -62,6 +62,7 @@ function WidgetGraphComponent({
|
|||||||
customErrorMessage,
|
customErrorMessage,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
customTimeRangeWindowForCoRelation,
|
customTimeRangeWindowForCoRelation,
|
||||||
|
enableDrillDown,
|
||||||
}: WidgetGraphComponentProps): JSX.Element {
|
}: WidgetGraphComponentProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
const [deleteModal, setDeleteModal] = useState(false);
|
||||||
@ -236,6 +237,8 @@ function WidgetGraphComponent({
|
|||||||
const onToggleModelHandler = (): void => {
|
const onToggleModelHandler = (): void => {
|
||||||
const existingSearchParams = new URLSearchParams(search);
|
const existingSearchParams = new URLSearchParams(search);
|
||||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||||
|
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||||
|
existingSearchParams.delete(QueryParams.graphType);
|
||||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||||
if (queryResponse.data?.payload) {
|
if (queryResponse.data?.payload) {
|
||||||
const {
|
const {
|
||||||
@ -365,6 +368,7 @@ function WidgetGraphComponent({
|
|||||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||||
customOnDragSelect={customOnDragSelect}
|
customOnDragSelect={customOnDragSelect}
|
||||||
setCurrentGraphRef={setCurrentGraphRef}
|
setCurrentGraphRef={setCurrentGraphRef}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@ -418,6 +422,7 @@ function WidgetGraphComponent({
|
|||||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||||
customSeries={customSeries}
|
customSeries={customSeries}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -430,6 +435,7 @@ WidgetGraphComponent.defaultProps = {
|
|||||||
setLayout: undefined,
|
setLayout: undefined,
|
||||||
onClickHandler: undefined,
|
onClickHandler: undefined,
|
||||||
customTimeRangeWindowForCoRelation: undefined,
|
customTimeRangeWindowForCoRelation: undefined,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WidgetGraphComponent;
|
export default WidgetGraphComponent;
|
||||||
|
|||||||
@ -52,6 +52,7 @@ function GridCardGraph({
|
|||||||
customTimeRange,
|
customTimeRange,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
customTimeRangeWindowForCoRelation,
|
customTimeRangeWindowForCoRelation,
|
||||||
|
enableDrillDown,
|
||||||
widgetsHavingDynamicVariables,
|
widgetsHavingDynamicVariables,
|
||||||
}: GridCardGraphProps): JSX.Element {
|
}: GridCardGraphProps): JSX.Element {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -330,6 +331,7 @@ function GridCardGraph({
|
|||||||
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -345,6 +347,7 @@ GridCardGraph.defaultProps = {
|
|||||||
version: 'v3',
|
version: 'v3',
|
||||||
analyticsEvent: undefined,
|
analyticsEvent: undefined,
|
||||||
customTimeRangeWindowForCoRelation: undefined,
|
customTimeRangeWindowForCoRelation: undefined,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(GridCardGraph);
|
export default memo(GridCardGraph);
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export interface WidgetGraphComponentProps {
|
|||||||
customErrorMessage?: string;
|
customErrorMessage?: string;
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridCardGraphProps {
|
export interface GridCardGraphProps {
|
||||||
@ -69,6 +70,7 @@ export interface GridCardGraphProps {
|
|||||||
};
|
};
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
widgetsHavingDynamicVariables?: Record<string, string[]>;
|
widgetsHavingDynamicVariables?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,11 +54,12 @@ import { WidgetRowHeader } from './WidgetRow';
|
|||||||
|
|
||||||
interface GraphLayoutProps {
|
interface GraphLayoutProps {
|
||||||
handle: FullScreenHandle;
|
handle: FullScreenHandle;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||||
const { handle } = props;
|
const { handle, enableDrillDown = false } = props;
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const {
|
const {
|
||||||
selectedDashboard,
|
selectedDashboard,
|
||||||
@ -601,6 +602,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
version={ENTITY_VERSION_V5}
|
version={ENTITY_VERSION_V5}
|
||||||
onDragSelect={onDragSelect}
|
onDragSelect={onDragSelect}
|
||||||
dataAvailable={checkIfDataExists}
|
dataAvailable={checkIfDataExists}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
widgetsHavingDynamicVariables={widgetsHavingDynamicVariables}
|
widgetsHavingDynamicVariables={widgetsHavingDynamicVariables}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@ -688,3 +690,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default GraphLayout;
|
export default GraphLayout;
|
||||||
|
|
||||||
|
GraphLayout.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@ -4,10 +4,17 @@ import GraphLayoutContainer from './GridCardLayout';
|
|||||||
|
|
||||||
interface GridGraphProps {
|
interface GridGraphProps {
|
||||||
handle: FullScreenHandle;
|
handle: FullScreenHandle;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
function GridGraph(props: GridGraphProps): JSX.Element {
|
function GridGraph(props: GridGraphProps): JSX.Element {
|
||||||
const { handle } = props;
|
const { handle, enableDrillDown = false } = props;
|
||||||
return <GraphLayoutContainer handle={handle} />;
|
return (
|
||||||
|
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GridGraph;
|
export default GridGraph;
|
||||||
|
|
||||||
|
GridGraph.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@ -46,6 +46,8 @@ function GridTableComponent({
|
|||||||
onOpenTraceBtnClick,
|
onOpenTraceBtnClick,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
widgetId,
|
widgetId,
|
||||||
|
panelType,
|
||||||
|
queryRangeRequest,
|
||||||
...props
|
...props
|
||||||
}: GridTableComponentProps): JSX.Element {
|
}: GridTableComponentProps): JSX.Element {
|
||||||
const { t } = useTranslation(['valueGraph']);
|
const { t } = useTranslation(['valueGraph']);
|
||||||
@ -266,6 +268,8 @@ function GridTableComponent({
|
|||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
sticky={sticky}
|
sticky={sticky}
|
||||||
widgetId={widgetId}
|
widgetId={widgetId}
|
||||||
|
panelType={panelType}
|
||||||
|
queryRangeRequest={queryRangeRequest}
|
||||||
onRow={
|
onRow={
|
||||||
openTracesButton || customOnRowClick
|
openTracesButton || customOnRowClick
|
||||||
? (record): React.HTMLAttributes<HTMLElement> => ({
|
? (record): React.HTMLAttributes<HTMLElement> => ({
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { TableProps } from 'antd';
|
import { TableProps } from 'antd';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
||||||
import {
|
import {
|
||||||
ThresholdOperators,
|
ThresholdOperators,
|
||||||
@ -6,8 +7,9 @@ import {
|
|||||||
} from 'container/NewWidget/RightContainer/Threshold/types';
|
} from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
||||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||||
import { ColumnUnit } from 'types/api/dashboard/getAll';
|
import { ColumnUnit, ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
export type GridTableComponentProps = {
|
export type GridTableComponentProps = {
|
||||||
query: Query;
|
query: Query;
|
||||||
@ -22,6 +24,10 @@ export type GridTableComponentProps = {
|
|||||||
widgetId?: string;
|
widgetId?: string;
|
||||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||||
customColTitles?: Record<string, string>;
|
customColTitles?: Record<string, string>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
queryRangeRequest?: QueryRangeRequestV5;
|
||||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import { ColumnsType, ColumnType } from 'antd/es/table';
|
import { ColumnType } from 'antd/es/table';
|
||||||
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
||||||
@ -9,6 +9,12 @@ import { isEmpty, isNaN } from 'lodash-es';
|
|||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { EQueryType } from 'types/common/dashboard';
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
|
||||||
|
// Custom column type that extends ColumnType to include isValueColumn
|
||||||
|
export interface CustomDataColumnType<T> extends ColumnType<T> {
|
||||||
|
isValueColumn?: boolean;
|
||||||
|
queryName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to evaluate the condition based on the operator
|
// Helper function to evaluate the condition based on the operator
|
||||||
function evaluateCondition(
|
function evaluateCondition(
|
||||||
operator: string | undefined,
|
operator: string | undefined,
|
||||||
@ -184,9 +190,9 @@ export function createColumnsAndDataSource(
|
|||||||
data: TableData,
|
data: TableData,
|
||||||
currentQuery: Query,
|
currentQuery: Query,
|
||||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||||
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
|
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
|
||||||
const columns: ColumnsType<RowData> =
|
const columns: CustomDataColumnType<RowData>[] =
|
||||||
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
|
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
|
||||||
// is the column is the value column then we need to check for the available legend
|
// is the column is the value column then we need to check for the available legend
|
||||||
const legend = item.isValueColumn
|
const legend = item.isValueColumn
|
||||||
? getQueryLegend(currentQuery, item.queryName)
|
? getQueryLegend(currentQuery, item.queryName)
|
||||||
@ -197,11 +203,13 @@ export function createColumnsAndDataSource(
|
|||||||
(query) => query.queryName === item.queryName,
|
(query) => query.queryName === item.queryName,
|
||||||
)?.aggregations?.length || 0;
|
)?.aggregations?.length || 0;
|
||||||
|
|
||||||
const column: ColumnType<RowData> = {
|
const column: CustomDataColumnType<RowData> = {
|
||||||
dataIndex: item.id || item.name,
|
dataIndex: item.id || item.name,
|
||||||
// if no legend present then rely on the column name value
|
// if no legend present then rely on the column name value
|
||||||
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
||||||
width: QUERY_TABLE_CONFIG.width,
|
width: QUERY_TABLE_CONFIG.width,
|
||||||
|
isValueColumn: item.isValueColumn,
|
||||||
|
queryName: item.queryName,
|
||||||
render: renderColumnCell && renderColumnCell[item.id],
|
render: renderColumnCell && renderColumnCell[item.id],
|
||||||
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import { Typography } from 'antd';
|
|||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
import ValueGraph from 'components/ValueGraph';
|
import ValueGraph from 'components/ValueGraph';
|
||||||
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
|
||||||
import { TitleContainer, ValueContainer } from './styles';
|
import { TitleContainer, ValueContainer } from './styles';
|
||||||
import { GridValueComponentProps } from './types';
|
import { GridValueComponentProps } from './types';
|
||||||
@ -13,6 +16,10 @@ function GridValueComponent({
|
|||||||
title,
|
title,
|
||||||
yAxisUnit,
|
yAxisUnit,
|
||||||
thresholds,
|
thresholds,
|
||||||
|
widget,
|
||||||
|
queryResponse,
|
||||||
|
contextLinks,
|
||||||
|
enableDrillDown = false,
|
||||||
}: GridValueComponentProps): JSX.Element {
|
}: GridValueComponentProps): JSX.Element {
|
||||||
const value = ((data[1] || [])[0] || 0) as number;
|
const value = ((data[1] || [])[0] || 0) as number;
|
||||||
|
|
||||||
@ -21,6 +28,39 @@ function GridValueComponent({
|
|||||||
|
|
||||||
const isDashboardPage = location.pathname.split('/').length === 3;
|
const isDashboardPage = location.pathname.split('/').length === 3;
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
clickedData,
|
||||||
|
} = useCoordinates();
|
||||||
|
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget?.id || '',
|
||||||
|
query: widget?.query || {
|
||||||
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
promql: [],
|
||||||
|
builder: {
|
||||||
|
queryFormulas: [],
|
||||||
|
queryData: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
clickhouse_sql: [],
|
||||||
|
id: '',
|
||||||
|
},
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks: contextLinks || { linksData: [] },
|
||||||
|
panelType: widget?.panelTypes,
|
||||||
|
queryRange: queryResponse,
|
||||||
|
});
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<ValueContainer>
|
<ValueContainer>
|
||||||
@ -29,12 +69,31 @@ function GridValueComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isQueryTypeBuilder =
|
||||||
|
widget?.query?.queryType === EQueryType.QUERY_BUILDER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitleContainer isDashboardPage={isDashboardPage}>
|
<TitleContainer isDashboardPage={isDashboardPage}>
|
||||||
<Typography>{gridTitle}</Typography>
|
<Typography>{gridTitle}</Typography>
|
||||||
</TitleContainer>
|
</TitleContainer>
|
||||||
<ValueContainer>
|
<ValueContainer
|
||||||
|
showClickable={enableDrillDown && isQueryTypeBuilder}
|
||||||
|
onClick={(e): void => {
|
||||||
|
const queryName = (queryResponse?.data?.params as any)?.compositeQuery
|
||||||
|
?.queries[0]?.spec?.name;
|
||||||
|
|
||||||
|
if (!enableDrillDown || !queryName || !isQueryTypeBuilder) return;
|
||||||
|
|
||||||
|
// when multiple queries are present, we need to get the query name from the queryResponse
|
||||||
|
// since value panel shows result for the first query
|
||||||
|
const clickedData = {
|
||||||
|
queryName,
|
||||||
|
filters: [],
|
||||||
|
};
|
||||||
|
onClick({ x: e.clientX, y: e.clientY }, clickedData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ValueGraph
|
<ValueGraph
|
||||||
thresholds={thresholds || []}
|
thresholds={thresholds || []}
|
||||||
rawValue={value}
|
rawValue={value}
|
||||||
@ -45,6 +104,13 @@ function GridValueComponent({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ValueContainer>
|
</ValueContainer>
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,19 @@ interface Props {
|
|||||||
isDashboardPage: boolean;
|
isDashboardPage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ValueContainer = styled.div`
|
interface ValueContainerProps {
|
||||||
|
showClickable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValueContainer = styled.div<ValueContainerProps>`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
cursor: ${({ showClickable = false }): string =>
|
||||||
|
showClickable ? 'pointer' : 'default'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TitleContainer = styled.div<Props>`
|
export const TitleContainer = styled.div<Props>`
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
|
import { UseQueryResult } from 'react-query';
|
||||||
|
import { SuccessResponse } from 'types/api';
|
||||||
|
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
|
||||||
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
|
|
||||||
export type GridValueComponentProps = {
|
export type GridValueComponentProps = {
|
||||||
@ -7,4 +11,12 @@ export type GridValueComponentProps = {
|
|||||||
title?: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
yAxisUnit?: string;
|
yAxisUnit?: string;
|
||||||
thresholds?: ThresholdProps[];
|
thresholds?: ThresholdProps[];
|
||||||
|
// Context menu related props
|
||||||
|
widget?: Widgets;
|
||||||
|
queryResponse?: UseQueryResult<
|
||||||
|
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||||
|
Error
|
||||||
|
>;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -50,7 +50,6 @@ function LogsExplorerList({
|
|||||||
isFilterApplied,
|
isFilterApplied,
|
||||||
}: LogsExplorerListProps): JSX.Element {
|
}: LogsExplorerListProps): JSX.Element {
|
||||||
const ref = useRef<VirtuosoHandle>(null);
|
const ref = useRef<VirtuosoHandle>(null);
|
||||||
|
|
||||||
const { activeLogId } = useCopyLogLink();
|
const { activeLogId } = useCopyLogLink();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -0,0 +1,239 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||||
|
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||||
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { convertVariablesToDbFormat } from './util';
|
||||||
|
|
||||||
|
// Note: This logic completely mimics the logic in DashboardVariableSelection.tsx
|
||||||
|
// but is separated to avoid unnecessary logic addition.
|
||||||
|
interface UseDashboardVariableUpdateReturn {
|
||||||
|
onValueUpdate: (
|
||||||
|
name: string,
|
||||||
|
id: string,
|
||||||
|
value: IDashboardVariable['selectedValue'],
|
||||||
|
allSelected: boolean,
|
||||||
|
haveCustomValuesSelected?: boolean,
|
||||||
|
) => void;
|
||||||
|
createVariable: (
|
||||||
|
name: string,
|
||||||
|
value: IDashboardVariable['selectedValue'],
|
||||||
|
// type?: IDashboardVariable['type'],
|
||||||
|
description?: string,
|
||||||
|
source?: 'logs' | 'traces' | 'metrics' | 'all sources',
|
||||||
|
widgetId?: string,
|
||||||
|
) => void;
|
||||||
|
updateVariables: (
|
||||||
|
updatedVariablesData: Dashboard['data']['variables'],
|
||||||
|
currentRequestedId?: string,
|
||||||
|
widgetIds?: string[],
|
||||||
|
applyToAll?: boolean,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
|
||||||
|
const {
|
||||||
|
selectedDashboard,
|
||||||
|
setSelectedDashboard,
|
||||||
|
updateLocalStorageDashboardVariables,
|
||||||
|
} = useDashboard();
|
||||||
|
|
||||||
|
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
|
||||||
|
const updateMutation = useUpdateDashboard();
|
||||||
|
|
||||||
|
const onValueUpdate = useCallback(
|
||||||
|
(
|
||||||
|
name: string,
|
||||||
|
id: string,
|
||||||
|
value: IDashboardVariable['selectedValue'],
|
||||||
|
allSelected: boolean,
|
||||||
|
haveCustomValuesSelected?: boolean,
|
||||||
|
): void => {
|
||||||
|
if (id) {
|
||||||
|
// Performance optimization: For dynamic variables with allSelected=true, we don't store
|
||||||
|
// individual values in localStorage since we can always derive them from available options.
|
||||||
|
// This makes localStorage much lighter and more efficient.
|
||||||
|
// currently all the variables are dynamic
|
||||||
|
const isDynamic = true;
|
||||||
|
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
|
||||||
|
|
||||||
|
if (selectedDashboard) {
|
||||||
|
setSelectedDashboard((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
const oldVariables = prev?.data.variables;
|
||||||
|
// this is added to handle case where we have two different
|
||||||
|
// schemas for variable response
|
||||||
|
if (oldVariables?.[id]) {
|
||||||
|
oldVariables[id] = {
|
||||||
|
...oldVariables[id],
|
||||||
|
selectedValue: value,
|
||||||
|
allSelected,
|
||||||
|
haveCustomValuesSelected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (oldVariables?.[name]) {
|
||||||
|
oldVariables[name] = {
|
||||||
|
...oldVariables[name],
|
||||||
|
selectedValue: value,
|
||||||
|
allSelected,
|
||||||
|
haveCustomValuesSelected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
data: {
|
||||||
|
...prev?.data,
|
||||||
|
variables: {
|
||||||
|
...oldVariables,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selectedDashboard,
|
||||||
|
setSelectedDashboard,
|
||||||
|
updateLocalStorageDashboardVariables,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateVariables = useCallback(
|
||||||
|
(
|
||||||
|
updatedVariablesData: Dashboard['data']['variables'],
|
||||||
|
currentRequestedId?: string,
|
||||||
|
widgetIds?: string[],
|
||||||
|
applyToAll?: boolean,
|
||||||
|
): void => {
|
||||||
|
if (!selectedDashboard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDashboard =
|
||||||
|
(currentRequestedId &&
|
||||||
|
addDynamicVariableToPanels(
|
||||||
|
selectedDashboard,
|
||||||
|
updatedVariablesData[currentRequestedId || ''],
|
||||||
|
widgetIds,
|
||||||
|
applyToAll,
|
||||||
|
)) ||
|
||||||
|
selectedDashboard;
|
||||||
|
|
||||||
|
updateMutation.mutateAsync(
|
||||||
|
{
|
||||||
|
id: selectedDashboard.id,
|
||||||
|
|
||||||
|
data: {
|
||||||
|
...newDashboard.data,
|
||||||
|
variables: updatedVariablesData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (updatedDashboard) => {
|
||||||
|
if (updatedDashboard.data) {
|
||||||
|
setSelectedDashboard(updatedDashboard.data);
|
||||||
|
// notifications.success({
|
||||||
|
// message: t('variable_updated_successfully'),
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selectedDashboard,
|
||||||
|
addDynamicVariableToPanels,
|
||||||
|
updateMutation,
|
||||||
|
setSelectedDashboard,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createVariable = useCallback(
|
||||||
|
(
|
||||||
|
name: string,
|
||||||
|
value: IDashboardVariable['selectedValue'],
|
||||||
|
// type: IDashboardVariable['type'] = 'DYNAMIC',
|
||||||
|
description = '',
|
||||||
|
source: 'logs' | 'traces' | 'metrics' | 'all sources' = 'all sources',
|
||||||
|
// widgetId?: string,
|
||||||
|
): void => {
|
||||||
|
if (!selectedDashboard) {
|
||||||
|
console.warn('No dashboard selected for variable creation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current dashboard variables
|
||||||
|
const currentVariables = selectedDashboard.data.variables || {};
|
||||||
|
|
||||||
|
// Create tableRowData like Dashboard Settings does
|
||||||
|
const tableRowData = [];
|
||||||
|
const variableOrderArr = [];
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const [key, value] of Object.entries(currentVariables)) {
|
||||||
|
const { order, id } = value;
|
||||||
|
|
||||||
|
tableRowData.push({
|
||||||
|
key,
|
||||||
|
name: key,
|
||||||
|
...currentVariables[key],
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order) {
|
||||||
|
variableOrderArr.push(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by order
|
||||||
|
tableRowData.sort((a, b) => a.order - b.order);
|
||||||
|
variableOrderArr.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Create new variable
|
||||||
|
const nextOrder =
|
||||||
|
variableOrderArr.length > 0 ? Math.max(...variableOrderArr) + 1 : 0;
|
||||||
|
const newVariable: any = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name,
|
||||||
|
type: 'DYNAMIC' as const,
|
||||||
|
description,
|
||||||
|
order: nextOrder,
|
||||||
|
selectedValue: value,
|
||||||
|
allSelected: false,
|
||||||
|
haveCustomValuesSelected: false,
|
||||||
|
sort: 'ASC' as const,
|
||||||
|
multiSelect: true,
|
||||||
|
showALLOption: true,
|
||||||
|
dynamicVariablesAttribute: name,
|
||||||
|
dynamicVariablesSource: source,
|
||||||
|
dynamicVariablesWidgetIds: [],
|
||||||
|
queryValue: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to tableRowData
|
||||||
|
tableRowData.push({
|
||||||
|
key: newVariable.id,
|
||||||
|
...newVariable,
|
||||||
|
id: newVariable.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to dashboard format and update
|
||||||
|
const updatedVariables = convertVariablesToDbFormat(tableRowData);
|
||||||
|
updateVariables(updatedVariables, newVariable.id, [], false);
|
||||||
|
},
|
||||||
|
[selectedDashboard, updateVariables],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onValueUpdate,
|
||||||
|
createVariable,
|
||||||
|
updateVariables,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDashboardVariableUpdate;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import GridGraphLayout from 'container/GridCardLayout';
|
import GridGraphLayout from 'container/GridCardLayout';
|
||||||
|
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
import { FullScreenHandle } from 'react-full-screen';
|
import { FullScreenHandle } from 'react-full-screen';
|
||||||
|
|
||||||
import { GridComponentSliderContainer } from './styles';
|
import { GridComponentSliderContainer } from './styles';
|
||||||
@ -11,7 +12,7 @@ function GridGraphs(props: GridGraphsProps): JSX.Element {
|
|||||||
const { handle } = props;
|
const { handle } = props;
|
||||||
return (
|
return (
|
||||||
<GridComponentSliderContainer>
|
<GridComponentSliderContainer>
|
||||||
<GridGraphLayout handle={handle} />
|
<GridGraphLayout handle={handle} enableDrillDown={isDrilldownEnabled()} />
|
||||||
</GridComponentSliderContainer>
|
</GridComponentSliderContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ function WidgetGraphContainer({
|
|||||||
setRequestData,
|
setRequestData,
|
||||||
selectedWidget,
|
selectedWidget,
|
||||||
isLoadingPanelData,
|
isLoadingPanelData,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphContainerProps): JSX.Element {
|
}: WidgetGraphContainerProps): JSX.Element {
|
||||||
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
|
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
|
||||||
const sortedSeriesData = getSortedSeriesData(
|
const sortedSeriesData = getSortedSeriesData(
|
||||||
@ -86,6 +87,7 @@ function WidgetGraphContainer({
|
|||||||
queryResponse={queryResponse}
|
queryResponse={queryResponse}
|
||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
selectedGraph={selectedGraph}
|
selectedGraph={selectedGraph}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ function WidgetGraph({
|
|||||||
queryResponse,
|
queryResponse,
|
||||||
setRequestData,
|
setRequestData,
|
||||||
selectedGraph,
|
selectedGraph,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphProps): JSX.Element {
|
}: WidgetGraphProps): JSX.Element {
|
||||||
const graphRef = useRef<HTMLDivElement>(null);
|
const graphRef = useRef<HTMLDivElement>(null);
|
||||||
const lineChartRef = useRef<ToggleGraphProps>();
|
const lineChartRef = useRef<ToggleGraphProps>();
|
||||||
@ -188,6 +189,7 @@ function WidgetGraph({
|
|||||||
onClickHandler={graphClickHandler}
|
onClickHandler={graphClickHandler}
|
||||||
graphVisibility={graphVisibility}
|
graphVisibility={graphVisibility}
|
||||||
setGraphVisibility={setGraphVisibility}
|
setGraphVisibility={setGraphVisibility}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -201,6 +203,11 @@ interface WidgetGraphProps {
|
|||||||
>;
|
>;
|
||||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||||
selectedGraph: PANEL_TYPES;
|
selectedGraph: PANEL_TYPES;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WidgetGraph;
|
export default WidgetGraph;
|
||||||
|
|
||||||
|
WidgetGraph.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@ -21,6 +21,7 @@ function WidgetGraph({
|
|||||||
setRequestData,
|
setRequestData,
|
||||||
selectedWidget,
|
selectedWidget,
|
||||||
isLoadingPanelData,
|
isLoadingPanelData,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphContainerProps): JSX.Element {
|
}: WidgetGraphContainerProps): JSX.Element {
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
@ -57,6 +58,7 @@ function WidgetGraph({
|
|||||||
queryResponse={queryResponse}
|
queryResponse={queryResponse}
|
||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
selectedWidget={selectedWidget}
|
selectedWidget={selectedWidget}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,6 +27,7 @@ function LeftContainer({
|
|||||||
setRequestData,
|
setRequestData,
|
||||||
isLoadingPanelData,
|
isLoadingPanelData,
|
||||||
setQueryResponse,
|
setQueryResponse,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphProps): JSX.Element {
|
}: WidgetGraphProps): JSX.Element {
|
||||||
const { stagedQuery } = useQueryBuilder();
|
const { stagedQuery } = useQueryBuilder();
|
||||||
// const { selectedDashboard } = useDashboard();
|
// const { selectedDashboard } = useDashboard();
|
||||||
@ -64,6 +65,7 @@ function LeftContainer({
|
|||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
selectedWidget={selectedWidget}
|
selectedWidget={selectedWidget}
|
||||||
isLoadingPanelData={isLoadingPanelData}
|
isLoadingPanelData={isLoadingPanelData}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
<QueryContainer className="query-section-left-container">
|
<QueryContainer className="query-section-left-container">
|
||||||
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />
|
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />
|
||||||
|
|||||||
@ -36,6 +36,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
.context-link-form-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
min-height: 500px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-url-parameter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-parameters-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.parameter-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-parameter-btn {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
padding: 4px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-cherry-400) !important;
|
||||||
|
border-color: var(--bg-cherry-400) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-container {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.context-link-form-container {
|
||||||
|
.url-parameters-section {
|
||||||
|
.parameter-row {
|
||||||
|
.delete-parameter-btn {
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-cherry-500) !important;
|
||||||
|
border-color: var(--bg-cherry-500) !important;
|
||||||
|
background-color: var(--bg-cherry-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-footer {
|
||||||
|
border-top-color: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,363 @@
|
|||||||
|
import './UpdateContextLinks.styles.scss';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input as AntInput,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { CONTEXT_LINK_FIELDS } from 'container/NewWidget/RightContainer/ContextLinks/constants';
|
||||||
|
import {
|
||||||
|
getInitialValues,
|
||||||
|
getUrlParams,
|
||||||
|
transformContextVariables,
|
||||||
|
updateUrlWithParams,
|
||||||
|
} from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||||
|
import useContextVariables from 'hooks/dashboard/useContextVariables';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ContextLinkProps, Widgets } from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
import VariablesDropdown from './VariablesDropdown';
|
||||||
|
|
||||||
|
const { TextArea } = AntInput;
|
||||||
|
|
||||||
|
interface UpdateContextLinksProps {
|
||||||
|
selectedContextLink: ContextLinkProps | null;
|
||||||
|
onSave: (newContextLink: ContextLinkProps) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
selectedWidget?: Widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdateContextLinks({
|
||||||
|
selectedContextLink,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
selectedWidget,
|
||||||
|
}: UpdateContextLinksProps): JSX.Element {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
// const label = Form.useWatch(CONTEXT_LINK_FIELDS.LABEL, form);
|
||||||
|
const url = Form.useWatch(CONTEXT_LINK_FIELDS.URL, form);
|
||||||
|
|
||||||
|
const [params, setParams] = useState<
|
||||||
|
{
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// Extract field variables from the widget's query (all groupBy fields from all queries)
|
||||||
|
const fieldVariables = useMemo(() => {
|
||||||
|
if (!selectedWidget?.query?.builder?.queryData) return {};
|
||||||
|
|
||||||
|
const fieldVars: Record<string, string | number | boolean> = {};
|
||||||
|
|
||||||
|
// Get all groupBy fields from all queries
|
||||||
|
selectedWidget.query.builder.queryData.forEach((queryData) => {
|
||||||
|
if (queryData.groupBy) {
|
||||||
|
queryData.groupBy.forEach((field) => {
|
||||||
|
if (field.key && !(field.key in fieldVars)) {
|
||||||
|
fieldVars[field.key] = ''; // Placeholder value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fieldVars;
|
||||||
|
}, [selectedWidget?.query]);
|
||||||
|
|
||||||
|
// Use useContextVariables to get dashboard, global, and field variables
|
||||||
|
const { variables } = useContextVariables({
|
||||||
|
maxValues: 2,
|
||||||
|
customVariables: fieldVariables,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform variables into the format expected by VariablesDropdown
|
||||||
|
const transformedVariables = useMemo(
|
||||||
|
() => transformContextVariables(variables),
|
||||||
|
[variables],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Function to get current domain
|
||||||
|
const getCurrentDomain = (): string => window.location.origin;
|
||||||
|
|
||||||
|
// Function to handle variable selection from dropdown
|
||||||
|
const handleVariableSelect = (
|
||||||
|
variableName: string,
|
||||||
|
cursorPosition?: number,
|
||||||
|
): void => {
|
||||||
|
// Get current URL value from form
|
||||||
|
const currentValue = form.getFieldValue(CONTEXT_LINK_FIELDS.URL) || '';
|
||||||
|
|
||||||
|
// Insert at cursor position if provided, otherwise append to end
|
||||||
|
const newValue =
|
||||||
|
cursorPosition !== undefined
|
||||||
|
? currentValue.slice(0, cursorPosition) +
|
||||||
|
variableName +
|
||||||
|
currentValue.slice(cursorPosition)
|
||||||
|
: currentValue + variableName;
|
||||||
|
|
||||||
|
// Update form value
|
||||||
|
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to handle variable selection for parameter values
|
||||||
|
const handleParamChange = (
|
||||||
|
index: number,
|
||||||
|
field: 'key' | 'value',
|
||||||
|
value: string,
|
||||||
|
): void => {
|
||||||
|
const newParams = [...params];
|
||||||
|
newParams[index][field] = value;
|
||||||
|
setParams(newParams);
|
||||||
|
const updatedUrl = updateUrlWithParams(url, newParams);
|
||||||
|
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParamVariableSelect = (
|
||||||
|
index: number,
|
||||||
|
variableName: string,
|
||||||
|
cursorPosition?: number,
|
||||||
|
): void => {
|
||||||
|
// Get current parameter value
|
||||||
|
const currentValue = params[index].value;
|
||||||
|
|
||||||
|
// Insert at cursor position if provided, otherwise append to end
|
||||||
|
const newValue =
|
||||||
|
cursorPosition !== undefined
|
||||||
|
? currentValue.slice(0, cursorPosition) +
|
||||||
|
variableName +
|
||||||
|
currentValue.slice(cursorPosition)
|
||||||
|
: currentValue + variableName;
|
||||||
|
|
||||||
|
// Update the parameter value
|
||||||
|
handleParamChange(index, 'value', newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
((window as unknown) as Record<string, unknown>).form = form;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
// Parse URL and update params when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (url) {
|
||||||
|
const urlParams = getUrlParams(url);
|
||||||
|
setParams(urlParams);
|
||||||
|
}
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Validate form fields
|
||||||
|
await form.validateFields();
|
||||||
|
const newContextLink = {
|
||||||
|
id: form.getFieldValue(CONTEXT_LINK_FIELDS.ID),
|
||||||
|
label:
|
||||||
|
form.getFieldValue(CONTEXT_LINK_FIELDS.LABEL) ||
|
||||||
|
form.getFieldValue(CONTEXT_LINK_FIELDS.URL),
|
||||||
|
url: form.getFieldValue(CONTEXT_LINK_FIELDS.URL),
|
||||||
|
};
|
||||||
|
// If validation passes, call onSave
|
||||||
|
onSave(newContextLink);
|
||||||
|
} catch (error) {
|
||||||
|
// Form validation failed, don't call onSave
|
||||||
|
console.log('Form validation failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUrlParameter = (): void => {
|
||||||
|
const isLastParamEmpty =
|
||||||
|
params.length > 0 &&
|
||||||
|
params[params.length - 1].key.trim() === '' &&
|
||||||
|
params[params.length - 1].value.trim() === '';
|
||||||
|
const canAddParam = params.length === 0 || !isLastParamEmpty;
|
||||||
|
|
||||||
|
if (canAddParam) {
|
||||||
|
const newParams = [
|
||||||
|
...params,
|
||||||
|
{
|
||||||
|
key: '',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setParams(newParams);
|
||||||
|
const updatedUrl = updateUrlWithParams(url, newParams);
|
||||||
|
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteParameter = (index: number): void => {
|
||||||
|
const newParams = params.filter((_, i) => i !== index);
|
||||||
|
setParams(newParams);
|
||||||
|
const updatedUrl = updateUrlWithParams(url, newParams);
|
||||||
|
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="context-link-form-container">
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
name="contextLink"
|
||||||
|
initialValues={getInitialValues(selectedContextLink)}
|
||||||
|
// onFinish={() => {}}
|
||||||
|
>
|
||||||
|
{/* //label */}
|
||||||
|
<Typography.Text className="form-label">Label</Typography.Text>
|
||||||
|
<Form.Item
|
||||||
|
name={CONTEXT_LINK_FIELDS.LABEL}
|
||||||
|
rules={[{ required: false, message: 'Please input the label' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="View Traces details: {{_traceId}}" />
|
||||||
|
</Form.Item>
|
||||||
|
{/* //url */}
|
||||||
|
<Typography.Text className="form-label">
|
||||||
|
URL <span className="required-asterisk">*</span>
|
||||||
|
</Typography.Text>
|
||||||
|
<Form.Item
|
||||||
|
name={CONTEXT_LINK_FIELDS.URL}
|
||||||
|
// label="URL"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Please input the URL' },
|
||||||
|
{
|
||||||
|
pattern: /^(https?:\/\/|\/|{{.*}}\/)/,
|
||||||
|
message: 'URLs must start with http(s), /, or {{.*}}/',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<VariablesDropdown
|
||||||
|
onVariableSelect={handleVariableSelect}
|
||||||
|
variables={transformedVariables}
|
||||||
|
>
|
||||||
|
{({ setIsOpen, setCursorPosition }): JSX.Element => (
|
||||||
|
<div className="url-input-trigger">
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(e): void => {
|
||||||
|
setCursorPosition(e.target.selectionStart || 0);
|
||||||
|
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, e.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={(): void => setIsOpen(true)}
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
onClick={(e): void =>
|
||||||
|
setCursorPosition((e.target as HTMLInputElement).selectionStart || 0)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
onKeyUp={(e): void =>
|
||||||
|
setCursorPosition((e.target as HTMLInputElement).selectionStart || 0)
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
className="url-input-field"
|
||||||
|
placeholder={`${getCurrentDomain()}/trace/{{_traceId}}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</VariablesDropdown>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Remove the separate variables section */}
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="params-container">
|
||||||
|
{/* URL Parameters Section */}
|
||||||
|
{params.length > 0 && (
|
||||||
|
<div className="url-parameters-section">
|
||||||
|
<Row gutter={[8, 8]} className="parameter-header">
|
||||||
|
<Col span={6}>Key</Col>
|
||||||
|
<Col span={16}>Value</Col>
|
||||||
|
<Col span={2}>{/* Empty column for spacing */}</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{params.map((param, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<Row gutter={[8, 8]} key={index} className="parameter-row">
|
||||||
|
<Col span={6}>
|
||||||
|
<Input
|
||||||
|
id={`param-key-${index}`}
|
||||||
|
placeholder="Key"
|
||||||
|
value={param.key}
|
||||||
|
onChange={(e): void =>
|
||||||
|
handleParamChange(index, 'key', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<VariablesDropdown
|
||||||
|
onVariableSelect={(variableName, cursorPosition): void =>
|
||||||
|
handleParamVariableSelect(index, variableName, cursorPosition)
|
||||||
|
}
|
||||||
|
variables={transformedVariables}
|
||||||
|
>
|
||||||
|
{({ setIsOpen, setCursorPosition }): JSX.Element => (
|
||||||
|
<TextArea
|
||||||
|
rows={1}
|
||||||
|
placeholder="Value"
|
||||||
|
value={param.value}
|
||||||
|
onChange={(event): void => {
|
||||||
|
setCursorPosition(event.target.selectionStart || 0);
|
||||||
|
handleParamChange(index, 'value', event.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={(): void => setIsOpen(true)}
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
onClick={(e): void =>
|
||||||
|
setCursorPosition(
|
||||||
|
(e.target as HTMLTextAreaElement).selectionStart || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
onKeyUp={(e): void =>
|
||||||
|
setCursorPosition(
|
||||||
|
(e.target as HTMLTextAreaElement).selectionStart || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VariablesDropdown>
|
||||||
|
</Col>
|
||||||
|
<Col span={2}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
onClick={(): void => handleDeleteParameter(index)}
|
||||||
|
className="delete-parameter-btn"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add URL parameter btn */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="add-url-parameter-btn"
|
||||||
|
icon={<Plus size={12} />}
|
||||||
|
onClick={handleAddUrlParameter}
|
||||||
|
>
|
||||||
|
Add URL parameter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with Cancel and Save buttons */}
|
||||||
|
<div className="context-link-footer">
|
||||||
|
<Button onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button type="primary" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateContextLinks.defaultProps = {
|
||||||
|
selectedWidget: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateContextLinks;
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
.variables-dropdown-container {
|
||||||
|
.url-input-trigger {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.url-input-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override Ant Design dropdown styles
|
||||||
|
.ant-dropdown-menu {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.variable-source {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import './VariablesDropdown.styles.scss';
|
||||||
|
|
||||||
|
import { Dropdown, Typography } from 'antd';
|
||||||
|
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface VariablesDropdownProps {
|
||||||
|
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||||
|
variables: VariableItem[];
|
||||||
|
children: (props: {
|
||||||
|
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
cursorPosition: number | null;
|
||||||
|
setCursorPosition: (position: number | null) => void;
|
||||||
|
}) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariableItem {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariablesDropdown({
|
||||||
|
onVariableSelect,
|
||||||
|
variables,
|
||||||
|
children,
|
||||||
|
}: VariablesDropdownProps): JSX.Element {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Click outside handler
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent): void {
|
||||||
|
if (
|
||||||
|
wrapperRef.current &&
|
||||||
|
!wrapperRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const dropdownItems = useMemo(
|
||||||
|
() =>
|
||||||
|
variables.map((v) => ({
|
||||||
|
key: v.name,
|
||||||
|
label: (
|
||||||
|
<div className="variable-row">
|
||||||
|
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
|
||||||
|
<Typography.Text className="variable-source">{v.source}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
[variables],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="variables-dropdown-container" ref={wrapperRef}>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: dropdownItems,
|
||||||
|
onClick: ({ key }): void => {
|
||||||
|
const variableName = key as string;
|
||||||
|
onVariableSelect(`{{${variableName}}}`, cursorPosition || undefined);
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
open={isOpen}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
getPopupContainer={(): HTMLElement => wrapperRef.current || document.body}
|
||||||
|
>
|
||||||
|
{children({
|
||||||
|
onVariableSelect,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
cursorPosition,
|
||||||
|
setCursorPosition,
|
||||||
|
})}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VariablesDropdown;
|
||||||
@ -0,0 +1,634 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import store from 'store';
|
||||||
|
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
import ContextLinks from '../index';
|
||||||
|
|
||||||
|
// Mock data for testing
|
||||||
|
const MOCK_EMPTY_CONTEXT_LINKS: ContextLinksData = {
|
||||||
|
linksData: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_CONTEXT_LINKS: ContextLinksData = {
|
||||||
|
linksData: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Dashboard 1',
|
||||||
|
url: 'https://example.com/dashboard1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'External Tool',
|
||||||
|
url: 'https://external.com/tool',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
label: 'Grafana',
|
||||||
|
url: 'https://grafana.example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test wrapper component
|
||||||
|
const renderWithProviders = (
|
||||||
|
component: React.ReactElement,
|
||||||
|
): ReturnType<typeof render> =>
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<MemoryRouter>{component}</MemoryRouter>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('ContextLinks Component', () => {
|
||||||
|
describe('Component Rendering & Initial State', () => {
|
||||||
|
it('should render correctly with existing context links', () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the component renders
|
||||||
|
expect(screen.getByText('Context Links')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that the add button is present
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /context link/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that all context link items are displayed
|
||||||
|
expect(screen.getByText('Dashboard 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('External Tool')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Grafana')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that URLs are displayed
|
||||||
|
expect(
|
||||||
|
screen.getByText('https://example.com/dashboard1'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('https://external.com/tool')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('https://grafana.example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Context Link" add button', () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the add button is present and has correct text
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
expect(addButton).toBeInTheDocument();
|
||||||
|
expect(addButton).toHaveTextContent('Context Link');
|
||||||
|
expect(addButton).toHaveClass('add-context-link-button');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Add Context Link Functionality', () => {
|
||||||
|
it('should show "Add a context link" title in modal when adding new link', () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the add button to open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Check that modal content is displayed
|
||||||
|
expect(screen.getByText('Add a context link')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that save and cancel buttons are present
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setContextLinks when saving new context link', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the add button to open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Fill in the form fields using placeholder text
|
||||||
|
const labelInput = screen.getByPlaceholderText(
|
||||||
|
'View Traces details: {{_traceId}}',
|
||||||
|
);
|
||||||
|
fireEvent.change(labelInput, { target: { value: 'New Link' } });
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
);
|
||||||
|
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||||
|
|
||||||
|
// Click save button in modal
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
// Wait for the modal to close and state to update
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that setContextLinks was called
|
||||||
|
expect(mockSetContextLinks).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// setContextLinks is called with a function (state updater)
|
||||||
|
const setContextLinksCall = mockSetContextLinks.mock.calls[0][0];
|
||||||
|
expect(typeof setContextLinksCall).toBe('function');
|
||||||
|
|
||||||
|
// Test the function by calling it with the current state
|
||||||
|
const result = setContextLinksCall(MOCK_EMPTY_CONTEXT_LINKS);
|
||||||
|
expect(result).toEqual({
|
||||||
|
linksData: [
|
||||||
|
{
|
||||||
|
id: expect.any(String), // ID is generated dynamically
|
||||||
|
label: 'New Link',
|
||||||
|
url: 'https://example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close modal when cancel button is clicked', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the add button to open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Modal should be visible
|
||||||
|
expect(screen.getByText('Add a context link')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click cancel button
|
||||||
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
// Modal should be closed
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call setContextLinks when cancel button is clicked', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the add button to open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Click cancel button
|
||||||
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that setContextLinks was not called
|
||||||
|
expect(mockSetContextLinks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show form fields in the modal', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the add button to open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Check that form field labels are present
|
||||||
|
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('URL')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that form field inputs are present using placeholder text
|
||||||
|
const labelInput = screen.getByPlaceholderText(
|
||||||
|
'View Traces details: {{_traceId}}',
|
||||||
|
);
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
);
|
||||||
|
expect(labelInput.tagName).toBe('INPUT');
|
||||||
|
expect(urlInput.tagName).toBe('INPUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate form fields before saving', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the add button to open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Try to save without filling required fields
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
// Form validation should prevent saving
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetContextLinks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal should still be open
|
||||||
|
expect(screen.getByText('Add a context link')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pre-populate form with existing data when editing a context link', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find and click the edit button for the first context link using CSS class
|
||||||
|
const editButtons = document.querySelectorAll('.edit-context-link-btn');
|
||||||
|
expect(editButtons).toHaveLength(3); // Should have 3 edit buttons for 3 context links
|
||||||
|
fireEvent.click(editButtons[0]); // Click edit button for first link
|
||||||
|
|
||||||
|
// Modal should open with "Edit context link" title
|
||||||
|
expect(screen.getByText('Edit context link')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Form should be pre-populated with existing data from the first context link
|
||||||
|
const labelInput = screen.getByPlaceholderText(
|
||||||
|
'View Traces details: {{_traceId}}',
|
||||||
|
);
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the form is pre-populated with the first context link's data
|
||||||
|
expect(labelInput).toHaveAttribute('value', 'Dashboard 1');
|
||||||
|
expect(urlInput).toHaveAttribute('value', 'https://example.com/dashboard1');
|
||||||
|
|
||||||
|
// Verify save and cancel buttons are present
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('URL and Query Parameter Functionality', () => {
|
||||||
|
it('should parse URL with query parameters and display them in parameter table', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal to add new context link
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Type a URL with query parameters
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
);
|
||||||
|
const testUrl =
|
||||||
|
'https://example.com/api?param1=value1¶m2=value2¶m3=value3';
|
||||||
|
fireEvent.change(urlInput, { target: { value: testUrl } });
|
||||||
|
|
||||||
|
// Wait for parameter parsing and display
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Key')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all parameters are displayed
|
||||||
|
expect(screen.getByDisplayValue('param1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('value1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('param2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('value2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('param3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('value3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add new URL parameter when "Add URL parameter" button is clicked', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Initially no parameters should be visible
|
||||||
|
expect(screen.queryByText('Key')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click "Add URL parameter" button
|
||||||
|
const addParamButton = screen.getByRole('button', {
|
||||||
|
name: /add url parameter/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(addParamButton);
|
||||||
|
|
||||||
|
// Parameter table should now be visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Key')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have one empty parameter row
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText('Key');
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText('Value');
|
||||||
|
expect(keyInputs).toHaveLength(1);
|
||||||
|
expect(valueInputs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update URL when parameter values are changed', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Add a parameter
|
||||||
|
const addParamButton = screen.getByRole('button', {
|
||||||
|
name: /add url parameter/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(addParamButton);
|
||||||
|
|
||||||
|
// Fill in parameter key and value
|
||||||
|
const keyInput = screen.getByPlaceholderText('Key');
|
||||||
|
const valueInput = screen.getAllByPlaceholderText('Value')[0];
|
||||||
|
|
||||||
|
fireEvent.change(keyInput, { target: { value: 'search' } });
|
||||||
|
fireEvent.change(valueInput, { target: { value: 'query' } });
|
||||||
|
|
||||||
|
// URL should be updated with the parameter
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(urlInput.value).toBe('?search=query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete URL parameter when delete button is clicked', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Add a parameter
|
||||||
|
const addParamButton = screen.getByRole('button', {
|
||||||
|
name: /add url parameter/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(addParamButton);
|
||||||
|
|
||||||
|
// Fill in parameter
|
||||||
|
const keyInput = screen.getByPlaceholderText('Key');
|
||||||
|
const valueInput = screen.getAllByPlaceholderText('Value')[0];
|
||||||
|
fireEvent.change(keyInput, { target: { value: 'test' } });
|
||||||
|
fireEvent.change(valueInput, { target: { value: 'value' } });
|
||||||
|
|
||||||
|
// Verify parameter is added
|
||||||
|
expect(screen.getByDisplayValue('test')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click delete button for the parameter
|
||||||
|
const deleteButtons = screen.getAllByRole('button', { name: '' });
|
||||||
|
const deleteButton = deleteButtons.find((btn) =>
|
||||||
|
btn.className.includes('delete-parameter-btn'),
|
||||||
|
);
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(deleteButton!);
|
||||||
|
|
||||||
|
// Parameter should be removed
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByDisplayValue('test')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL should be cleaned up
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(urlInput.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple parameters and maintain URL synchronization', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Add first parameter
|
||||||
|
const addParamButton = screen.getByRole('button', {
|
||||||
|
name: /add url parameter/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(addParamButton);
|
||||||
|
|
||||||
|
// Fill first parameter
|
||||||
|
let keyInputs = screen.getAllByPlaceholderText('Key');
|
||||||
|
let valueInputs = screen.getAllByPlaceholderText('Value');
|
||||||
|
fireEvent.change(keyInputs[0], { target: { value: 'page' } });
|
||||||
|
fireEvent.change(valueInputs[0], { target: { value: '1' } });
|
||||||
|
|
||||||
|
// Add second parameter
|
||||||
|
fireEvent.click(addParamButton);
|
||||||
|
|
||||||
|
// Get updated inputs after adding second parameter
|
||||||
|
keyInputs = screen.getAllByPlaceholderText('Key');
|
||||||
|
valueInputs = screen.getAllByPlaceholderText('Value');
|
||||||
|
|
||||||
|
// Fill second parameter
|
||||||
|
fireEvent.change(keyInputs[1], { target: { value: 'size' } });
|
||||||
|
fireEvent.change(valueInputs[1], { target: { value: '10' } });
|
||||||
|
|
||||||
|
// URL should contain both parameters
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(urlInput.value).toBe('?page=1&size=10');
|
||||||
|
|
||||||
|
// Change first parameter value
|
||||||
|
fireEvent.change(valueInputs[0], { target: { value: '2' } });
|
||||||
|
|
||||||
|
// URL should be updated
|
||||||
|
expect(urlInput.value).toBe('?page=2&size=10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate URL format and show appropriate error messages', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Try to save with invalid URL
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
);
|
||||||
|
fireEvent.change(urlInput, { target: { value: 'invalid-url' } });
|
||||||
|
|
||||||
|
// Try to save
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('URLs must start with http(s), /, or {{.*}}/'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// setContextLinks should not be called due to validation failure
|
||||||
|
expect(mockSetContextLinks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in parameter keys and values correctly', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Add parameter with special characters
|
||||||
|
const addParamButton = screen.getByRole('button', {
|
||||||
|
name: /add url parameter/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(addParamButton);
|
||||||
|
|
||||||
|
// Fill parameter with special characters
|
||||||
|
const keyInput = screen.getByPlaceholderText('Key');
|
||||||
|
const valueInput = screen.getAllByPlaceholderText('Value')[0];
|
||||||
|
|
||||||
|
fireEvent.change(keyInput, { target: { value: 'user@domain' } });
|
||||||
|
fireEvent.change(valueInput, { target: { value: 'John Doe & Co.' } });
|
||||||
|
|
||||||
|
// URL should be properly encoded
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(urlInput.value).toBe('?user%40domain=John%20Doe%20%26%20Co.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support template variables in URL and parameters', async () => {
|
||||||
|
const mockSetContextLinks = jest.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||||
|
setContextLinks={mockSetContextLinks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
// Type URL with template variable
|
||||||
|
const urlInput = screen.getByPlaceholderText(
|
||||||
|
'http://localhost/trace/{{_traceId}}',
|
||||||
|
);
|
||||||
|
const testUrl =
|
||||||
|
'https://example.com/trace/{{_traceId}}?service={{_serviceName}}';
|
||||||
|
fireEvent.change(urlInput, { target: { value: testUrl } });
|
||||||
|
|
||||||
|
// Wait for parameter parsing
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Key')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should parse template variable as parameter
|
||||||
|
expect(screen.getByDisplayValue('service')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('{{_serviceName}}')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// URL should maintain template variables
|
||||||
|
expect((urlInput as HTMLInputElement).value).toBe(
|
||||||
|
'https://example.com/trace/{{_traceId}}?service={{_serviceName}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export const CONTEXT_LINK_FIELDS = {
|
||||||
|
ID: 'id',
|
||||||
|
LABEL: 'label',
|
||||||
|
URL: 'url',
|
||||||
|
// OPEN_IN_NEW_TAB: 'openInNewTab'
|
||||||
|
};
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
/* eslint-disable react/jsx-props-no-spreading */
|
||||||
|
import './styles.scss';
|
||||||
|
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { Button, Modal, Typography } from 'antd';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import {
|
||||||
|
ContextLinkProps,
|
||||||
|
ContextLinksData,
|
||||||
|
Widgets,
|
||||||
|
} from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
import UpdateContextLinks from './UpdateContextLinks';
|
||||||
|
import useContextLinkModal from './useContextLinkModal';
|
||||||
|
|
||||||
|
function SortableContextLink({
|
||||||
|
contextLink,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
contextLink: ContextLinkProps;
|
||||||
|
onDelete: (contextLink: ContextLinkProps) => void;
|
||||||
|
onEdit: (contextLink: ContextLinkProps) => void;
|
||||||
|
}): JSX.Element {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({ id: contextLink.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="context-link-item drag-enabled"
|
||||||
|
>
|
||||||
|
<div {...attributes} {...listeners} className="drag-handle">
|
||||||
|
<div className="drag-handle-icon">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="context-link-content">
|
||||||
|
<span className="context-link-label">{contextLink.label}</span>
|
||||||
|
<span className="context-link-url">{contextLink.url}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="context-link-actions">
|
||||||
|
<Button
|
||||||
|
className="edit-context-link-btn periscope-btn"
|
||||||
|
size="small"
|
||||||
|
icon={<Pencil size={12} />}
|
||||||
|
onClick={(): void => {
|
||||||
|
onEdit(contextLink);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="delete-context-link-btn periscope-btn"
|
||||||
|
size="small"
|
||||||
|
icon={<Trash2 size={12} />}
|
||||||
|
onClick={(): void => {
|
||||||
|
onDelete(contextLink);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextLinks({
|
||||||
|
contextLinks,
|
||||||
|
setContextLinks,
|
||||||
|
selectedWidget,
|
||||||
|
}: {
|
||||||
|
contextLinks: ContextLinksData;
|
||||||
|
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||||
|
selectedWidget?: Widgets;
|
||||||
|
}): JSX.Element {
|
||||||
|
// Use the custom hook for modal functionality
|
||||||
|
const {
|
||||||
|
isModalOpen,
|
||||||
|
selectedContextLink,
|
||||||
|
handleEditContextLink,
|
||||||
|
handleAddContextLink,
|
||||||
|
handleCancelModal,
|
||||||
|
handleSaveContextLink,
|
||||||
|
} = useContextLinkModal({ setContextLinks });
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent): void => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setContextLinks((prev) => {
|
||||||
|
const items = [...prev.linksData];
|
||||||
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||||
|
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
linksData: arrayMove(items, oldIndex, newIndex),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteContextLink = (contextLink: ContextLinkProps): void => {
|
||||||
|
setContextLinks((prev) => ({
|
||||||
|
...prev,
|
||||||
|
linksData: prev.linksData.filter((link) => link.id !== contextLink.id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="context-links-container">
|
||||||
|
<Typography.Text className="context-links-text">
|
||||||
|
Context Links
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<div className="context-links-list">
|
||||||
|
<OverlayScrollbar>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={contextLinks.linksData.map((link) => link.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{contextLinks.linksData.map((contextLink) => (
|
||||||
|
<SortableContextLink
|
||||||
|
key={contextLink.id}
|
||||||
|
contextLink={contextLink}
|
||||||
|
onDelete={handleDeleteContextLink}
|
||||||
|
onEdit={handleEditContextLink}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</OverlayScrollbar>
|
||||||
|
|
||||||
|
{/* button to add context link */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="add-context-link-button"
|
||||||
|
icon={<Plus size={12} />}
|
||||||
|
onClick={handleAddContextLink}
|
||||||
|
>
|
||||||
|
Context Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={selectedContextLink ? 'Edit context link' : 'Add a context link'}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={handleCancelModal}
|
||||||
|
destroyOnClose
|
||||||
|
width={672}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<UpdateContextLinks
|
||||||
|
selectedContextLink={selectedContextLink}
|
||||||
|
onSave={handleSaveContextLink}
|
||||||
|
onCancel={handleCancelModal}
|
||||||
|
selectedWidget={selectedWidget}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextLinks.defaultProps = {
|
||||||
|
selectedWidget: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContextLinks;
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
.context-links-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-links-text {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: 0.52px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
cursor: grab;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-label {
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-url {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-context-link-btn,
|
||||||
|
.delete-context-link-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-context-link-btn {
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-cherry-400) !important;
|
||||||
|
border-color: var(--bg-cherry-400) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-slate-400);
|
||||||
|
|
||||||
|
.context-link-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-context-link-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.context-links-text {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-item {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-label {
|
||||||
|
color: var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-link-url {
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle-icon {
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-context-link-btn {
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-cherry-500);
|
||||||
|
border-color: var(--bg-cherry-500);
|
||||||
|
background-color: var(--bg-cherry-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { Dispatch, SetStateAction, useState } from 'react';
|
||||||
|
import { ContextLinkProps, ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
interface ContextLinkModalProps {
|
||||||
|
isModalOpen: boolean;
|
||||||
|
selectedContextLink: ContextLinkProps | null;
|
||||||
|
handleEditContextLink: (contextLink: ContextLinkProps) => void;
|
||||||
|
handleAddContextLink: () => void;
|
||||||
|
handleCancelModal: () => void;
|
||||||
|
handleSaveContextLink: (newContextLink: ContextLinkProps) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useContextLinkModal = ({
|
||||||
|
setContextLinks,
|
||||||
|
}: {
|
||||||
|
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||||
|
}): ContextLinkModalProps => {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [
|
||||||
|
selectedContextLink,
|
||||||
|
setSelectedContextLink,
|
||||||
|
] = useState<ContextLinkProps | null>(null);
|
||||||
|
|
||||||
|
const handleEditContextLink = (contextLink: ContextLinkProps): void => {
|
||||||
|
setSelectedContextLink(contextLink);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddContextLink = (): void => {
|
||||||
|
setSelectedContextLink(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelModal = (): void => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedContextLink(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveContextLink = (newContextLink: ContextLinkProps): void => {
|
||||||
|
setContextLinks((prev) => {
|
||||||
|
const links = [...prev.linksData];
|
||||||
|
const existing = links.filter((link) => link.id === newContextLink.id)[0];
|
||||||
|
if (existing) {
|
||||||
|
const idx = links.findIndex((link) => link.id === newContextLink.id);
|
||||||
|
links[idx] = { ...existing, ...newContextLink };
|
||||||
|
return { ...prev, linksData: links };
|
||||||
|
}
|
||||||
|
links.push(newContextLink);
|
||||||
|
return { ...prev, linksData: links };
|
||||||
|
});
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedContextLink(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isModalOpen,
|
||||||
|
selectedContextLink,
|
||||||
|
handleEditContextLink,
|
||||||
|
handleAddContextLink,
|
||||||
|
handleCancelModal,
|
||||||
|
handleSaveContextLink,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useContextLinkModal;
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
import { CONTEXT_LINK_FIELDS } from 'container/NewWidget/RightContainer/ContextLinks/constants';
|
||||||
|
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||||
|
import { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
// Configuration for variable source types
|
||||||
|
export const VARIABLE_SOURCE_CONFIG = {
|
||||||
|
TIMESTAMP: {
|
||||||
|
label: 'Global timestamp',
|
||||||
|
},
|
||||||
|
QUERY: {
|
||||||
|
label: 'Query variable',
|
||||||
|
},
|
||||||
|
GLOBAL: {
|
||||||
|
label: 'Global variable',
|
||||||
|
},
|
||||||
|
DASHBOARD: {
|
||||||
|
label: 'Dashboard variable',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface ContextVariable {
|
||||||
|
name: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransformedVariable {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UrlParam {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessedContextLink {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitialValues = (
|
||||||
|
contextLink: ContextLinkProps | null,
|
||||||
|
): Record<string, string> => ({
|
||||||
|
[CONTEXT_LINK_FIELDS.ID]: contextLink?.id || uuid(),
|
||||||
|
[CONTEXT_LINK_FIELDS.LABEL]: contextLink?.label || '',
|
||||||
|
[CONTEXT_LINK_FIELDS.URL]: contextLink?.url || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUrlParams = (url: string): UrlParam[] => {
|
||||||
|
try {
|
||||||
|
const [, queryString] = url.split('?');
|
||||||
|
|
||||||
|
if (!queryString) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramPairs = queryString.split('&');
|
||||||
|
const params: UrlParam[] = [];
|
||||||
|
|
||||||
|
paramPairs.forEach((pair) => {
|
||||||
|
try {
|
||||||
|
const [key, value] = pair.split('=');
|
||||||
|
if (key) {
|
||||||
|
const decodedKey = decodeURIComponent(key);
|
||||||
|
const decodedValue = decodeURIComponent(value || '');
|
||||||
|
|
||||||
|
// Double decode the value for display
|
||||||
|
let displayValue = decodedValue;
|
||||||
|
try {
|
||||||
|
// Try to double decode if it looks like it was double encoded
|
||||||
|
const doubleDecoded = decodeURIComponent(decodedValue);
|
||||||
|
// Check if double decoding produced a different result
|
||||||
|
if (doubleDecoded !== decodedValue) {
|
||||||
|
displayValue = doubleDecoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If double decoding fails, use single decoded value
|
||||||
|
displayValue = decodedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push({
|
||||||
|
key: decodedKey,
|
||||||
|
value: displayValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (paramError) {
|
||||||
|
// Skip malformed parameters and continue processing
|
||||||
|
console.warn('Failed to parse URL parameter:', pair, paramError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return params;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse URL parameters, returning empty array:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUrlWithParams = (url: string, params: UrlParam[]): string => {
|
||||||
|
// Get base URL without query parameters
|
||||||
|
const [baseUrl] = url.split('?');
|
||||||
|
|
||||||
|
// Create query parameter string from current parameters
|
||||||
|
const validParams = params.filter((param) => param.key.trim() !== '');
|
||||||
|
const queryString = validParams
|
||||||
|
.map(
|
||||||
|
(param) =>
|
||||||
|
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
|
||||||
|
param.value,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
// Construct final URL
|
||||||
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to process context links with variable resolution and URL encoding
|
||||||
|
const processContextLinks = (
|
||||||
|
contextLinks: ContextLinkProps[],
|
||||||
|
processedVariables: Record<string, string>,
|
||||||
|
maxLength?: number,
|
||||||
|
): ProcessedContextLink[] => {
|
||||||
|
// Extract all labels and URLs for batch processing
|
||||||
|
const labels = contextLinks.map(({ label }) => label);
|
||||||
|
const urls = contextLinks.map(({ url }) => url);
|
||||||
|
|
||||||
|
// Resolve variables in labels
|
||||||
|
const resolvedLabels = resolveTexts({
|
||||||
|
texts: labels,
|
||||||
|
processedVariables,
|
||||||
|
maxLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process URLs with proper encoding/decoding
|
||||||
|
const finalUrls = urls.map((url) => {
|
||||||
|
if (typeof url !== 'string') return url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get the URL and extract base URL and query string
|
||||||
|
const [baseUrl, queryString] = url.split('?');
|
||||||
|
// Resolve variables in base URL.
|
||||||
|
const resolvedBaseUrlResult = resolveTexts({
|
||||||
|
texts: [baseUrl],
|
||||||
|
processedVariables,
|
||||||
|
});
|
||||||
|
const resolvedBaseUrl = resolvedBaseUrlResult.fullTexts[0];
|
||||||
|
|
||||||
|
if (!queryString) return resolvedBaseUrl;
|
||||||
|
|
||||||
|
// 2. Extract all query params using URLSearchParams
|
||||||
|
const searchParams = new URLSearchParams(queryString);
|
||||||
|
const processedParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 3. Process each parameter
|
||||||
|
Array.from(searchParams.entries()).forEach(([key, value]) => {
|
||||||
|
// 4. Decode twice to handle double encoding
|
||||||
|
let decodedValue = decodeURIComponent(value);
|
||||||
|
try {
|
||||||
|
const doubleDecoded = decodeURIComponent(decodedValue);
|
||||||
|
// Check if double decoding produced a different result
|
||||||
|
if (doubleDecoded !== decodedValue) {
|
||||||
|
decodedValue = doubleDecoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If double decoding fails, use single decoded value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Pass through resolve text for variable resolution
|
||||||
|
const resolvedTextsResult = resolveTexts({
|
||||||
|
texts: [decodedValue],
|
||||||
|
processedVariables,
|
||||||
|
});
|
||||||
|
const resolvedValue = resolvedTextsResult.fullTexts[0];
|
||||||
|
|
||||||
|
// 6. Encode the resolved value
|
||||||
|
processedParams[key] = encodeURIComponent(resolvedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Create new URL with processed parameters
|
||||||
|
const newQueryString = Object.entries(processedParams)
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${value}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
return `${resolvedBaseUrl}?${newQueryString}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to process URL, using original URL:', error);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return processed context links
|
||||||
|
return contextLinks.map((link, index) => ({
|
||||||
|
id: link.id,
|
||||||
|
label: resolvedLabels.fullTexts[index],
|
||||||
|
url: finalUrls[index],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms context variables into the format expected by VariablesDropdown
|
||||||
|
* @param variables - Array of context variables from useContextVariables
|
||||||
|
* @returns Array of transformed variables with proper source descriptions
|
||||||
|
*/
|
||||||
|
export const transformContextVariables = (
|
||||||
|
variables: ContextVariable[],
|
||||||
|
): TransformedVariable[] => {
|
||||||
|
const groupedVars: { [key: string]: TransformedVariable[] } = {};
|
||||||
|
|
||||||
|
// Process variables array from useContextVariables
|
||||||
|
variables.forEach((variable) => {
|
||||||
|
let source = VARIABLE_SOURCE_CONFIG.DASHBOARD.label as string; // Default to dashboard
|
||||||
|
|
||||||
|
// Check if it's a timestamp variable (special case - use name-based detection)
|
||||||
|
if (variable.name.toLowerCase().includes('timestamp')) {
|
||||||
|
source = VARIABLE_SOURCE_CONFIG.TIMESTAMP.label;
|
||||||
|
}
|
||||||
|
// Use the actual source property from the variable
|
||||||
|
else if (variable.source === 'global') {
|
||||||
|
source = VARIABLE_SOURCE_CONFIG.GLOBAL.label;
|
||||||
|
} else if (variable.source === 'custom') {
|
||||||
|
source = VARIABLE_SOURCE_CONFIG.QUERY.label;
|
||||||
|
} else if (variable.source === 'dashboard') {
|
||||||
|
source = VARIABLE_SOURCE_CONFIG.DASHBOARD.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group variables by source
|
||||||
|
if (!groupedVars[source]) {
|
||||||
|
groupedVars[source] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedVars[source].push({
|
||||||
|
name: variable.name,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten the grouped variables to maintain source grouping order
|
||||||
|
const allVars: TransformedVariable[] = [];
|
||||||
|
|
||||||
|
// Add variables in the order we want them to appear
|
||||||
|
const sourceOrder = [
|
||||||
|
VARIABLE_SOURCE_CONFIG.TIMESTAMP.label,
|
||||||
|
VARIABLE_SOURCE_CONFIG.GLOBAL.label,
|
||||||
|
VARIABLE_SOURCE_CONFIG.QUERY.label,
|
||||||
|
VARIABLE_SOURCE_CONFIG.DASHBOARD.label,
|
||||||
|
];
|
||||||
|
|
||||||
|
sourceOrder.forEach((source) => {
|
||||||
|
if (groupedVars[source]) {
|
||||||
|
allVars.push(...groupedVars[source]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return allVars;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
getInitialValues,
|
||||||
|
getUrlParams,
|
||||||
|
processContextLinks,
|
||||||
|
updateUrlWithParams,
|
||||||
|
};
|
||||||
@ -335,6 +335,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-links {
|
||||||
|
border-bottom: 1px solid var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
|
||||||
.alerts {
|
.alerts {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@ -512,6 +516,10 @@
|
|||||||
color: var(--bg-ink-300);
|
color: var(--bg-ink-300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-links {
|
||||||
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-option {
|
.select-option {
|
||||||
|
|||||||
@ -178,3 +178,17 @@ export const panelTypeVsLegendColors: {
|
|||||||
[PANEL_TYPES.HISTOGRAM]: true,
|
[PANEL_TYPES.HISTOGRAM]: true,
|
||||||
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const panelTypeVsContextLinks: {
|
||||||
|
[key in PANEL_TYPES]: boolean;
|
||||||
|
} = {
|
||||||
|
[PANEL_TYPES.TIME_SERIES]: true,
|
||||||
|
[PANEL_TYPES.VALUE]: true,
|
||||||
|
[PANEL_TYPES.TABLE]: true,
|
||||||
|
[PANEL_TYPES.LIST]: false,
|
||||||
|
[PANEL_TYPES.PIE]: true,
|
||||||
|
[PANEL_TYPES.BAR]: true,
|
||||||
|
[PANEL_TYPES.HISTOGRAM]: true,
|
||||||
|
[PANEL_TYPES.TRACE]: false,
|
||||||
|
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||||
|
} as const;
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { UseQueryResult } from 'react-query';
|
|||||||
import { SuccessResponse } from 'types/api';
|
import { SuccessResponse } from 'types/api';
|
||||||
import {
|
import {
|
||||||
ColumnUnit,
|
ColumnUnit,
|
||||||
|
ContextLinksData,
|
||||||
LegendPosition,
|
LegendPosition,
|
||||||
Widgets,
|
Widgets,
|
||||||
} from 'types/api/dashboard/getAll';
|
} from 'types/api/dashboard/getAll';
|
||||||
@ -45,6 +46,7 @@ import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
|||||||
import {
|
import {
|
||||||
panelTypeVsBucketConfig,
|
panelTypeVsBucketConfig,
|
||||||
panelTypeVsColumnUnitPreferences,
|
panelTypeVsColumnUnitPreferences,
|
||||||
|
panelTypeVsContextLinks,
|
||||||
panelTypeVsCreateAlert,
|
panelTypeVsCreateAlert,
|
||||||
panelTypeVsFillSpan,
|
panelTypeVsFillSpan,
|
||||||
panelTypeVsLegendColors,
|
panelTypeVsLegendColors,
|
||||||
@ -56,6 +58,7 @@ import {
|
|||||||
panelTypeVsThreshold,
|
panelTypeVsThreshold,
|
||||||
panelTypeVsYAxisUnit,
|
panelTypeVsYAxisUnit,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import ContextLinks from './ContextLinks';
|
||||||
import LegendColors from './LegendColors/LegendColors';
|
import LegendColors from './LegendColors/LegendColors';
|
||||||
import ThresholdSelector from './Threshold/ThresholdSelector';
|
import ThresholdSelector from './Threshold/ThresholdSelector';
|
||||||
import { ThresholdProps } from './Threshold/types';
|
import { ThresholdProps } from './Threshold/types';
|
||||||
@ -113,6 +116,9 @@ function RightContainer({
|
|||||||
customLegendColors,
|
customLegendColors,
|
||||||
setCustomLegendColors,
|
setCustomLegendColors,
|
||||||
queryResponse,
|
queryResponse,
|
||||||
|
contextLinks,
|
||||||
|
setContextLinks,
|
||||||
|
enableDrillDown = false,
|
||||||
}: RightContainerProps): JSX.Element {
|
}: RightContainerProps): JSX.Element {
|
||||||
const { selectedDashboard } = useDashboard();
|
const { selectedDashboard } = useDashboard();
|
||||||
const [inputValue, setInputValue] = useState(title);
|
const [inputValue, setInputValue] = useState(title);
|
||||||
@ -152,6 +158,8 @@ function RightContainer({
|
|||||||
|
|
||||||
const allowPanelColumnPreference =
|
const allowPanelColumnPreference =
|
||||||
panelTypeVsColumnUnitPreferences[selectedGraph];
|
panelTypeVsColumnUnitPreferences[selectedGraph];
|
||||||
|
const allowContextLinks =
|
||||||
|
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
|
||||||
|
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
@ -497,6 +505,16 @@ function RightContainer({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{allowContextLinks && (
|
||||||
|
<section className="context-links">
|
||||||
|
<ContextLinks
|
||||||
|
contextLinks={contextLinks}
|
||||||
|
setContextLinks={setContextLinks}
|
||||||
|
selectedWidget={selectedWidget}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{allowThreshold && (
|
{allowThreshold && (
|
||||||
<section>
|
<section>
|
||||||
<ThresholdSelector
|
<ThresholdSelector
|
||||||
@ -558,11 +576,15 @@ interface RightContainerProps {
|
|||||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||||
Error
|
Error
|
||||||
>;
|
>;
|
||||||
|
contextLinks: ContextLinksData;
|
||||||
|
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
RightContainer.defaultProps = {
|
RightContainer.defaultProps = {
|
||||||
selectedWidget: undefined,
|
selectedWidget: undefined,
|
||||||
queryResponse: null,
|
queryResponse: null,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RightContainer;
|
export default RightContainer;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
|||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import createQueryParams from 'lib/createQueryParams';
|
||||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||||
@ -41,6 +42,7 @@ import { AppState } from 'store/reducers';
|
|||||||
import { SuccessResponse } from 'types/api';
|
import { SuccessResponse } from 'types/api';
|
||||||
import {
|
import {
|
||||||
ColumnUnit,
|
ColumnUnit,
|
||||||
|
ContextLinksData,
|
||||||
LegendPosition,
|
LegendPosition,
|
||||||
Widgets,
|
Widgets,
|
||||||
} from 'types/api/dashboard/getAll';
|
} from 'types/api/dashboard/getAll';
|
||||||
@ -72,7 +74,10 @@ import {
|
|||||||
placeWidgetBetweenRows,
|
placeWidgetBetweenRows,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
function NewWidget({
|
||||||
|
selectedGraph,
|
||||||
|
enableDrillDown = false,
|
||||||
|
}: NewWidgetProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const {
|
const {
|
||||||
selectedDashboard,
|
selectedDashboard,
|
||||||
@ -239,6 +244,10 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
selectedWidget?.columnUnits || {},
|
selectedWidget?.columnUnits || {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [contextLinks, setContextLinks] = useState<ContextLinksData>(
|
||||||
|
selectedWidget?.contextLinks || { linksData: [] },
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedWidget((prev) => {
|
setSelectedWidget((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@ -268,6 +277,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
legendPosition,
|
legendPosition,
|
||||||
customLegendColors,
|
customLegendColors,
|
||||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||||
|
contextLinks,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -294,6 +304,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
legendPosition,
|
legendPosition,
|
||||||
customLegendColors,
|
customLegendColors,
|
||||||
columnWidths,
|
columnWidths,
|
||||||
|
contextLinks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const closeModal = (): void => {
|
const closeModal = (): void => {
|
||||||
@ -504,6 +515,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
||||||
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
||||||
customLegendColors: selectedWidget?.customLegendColors || {},
|
customLegendColors: selectedWidget?.customLegendColors || {},
|
||||||
|
contextLinks: selectedWidget?.contextLinks || { linksData: [] },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@ -533,6 +545,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
||||||
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
||||||
customLegendColors: selectedWidget?.customLegendColors || {},
|
customLegendColors: selectedWidget?.customLegendColors || {},
|
||||||
|
contextLinks: selectedWidget?.contextLinks || { linksData: [] },
|
||||||
},
|
},
|
||||||
...afterWidgets,
|
...afterWidgets,
|
||||||
],
|
],
|
||||||
@ -598,6 +611,15 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// add useEffect for graph type change from url
|
||||||
|
useEffect(() => {
|
||||||
|
const graphType = query.get('graphType');
|
||||||
|
if (graphType && graphType !== selectedGraph) {
|
||||||
|
setGraphType(graphType as PANEL_TYPES);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
const onSaveDashboard = useCallback((): void => {
|
const onSaveDashboard = useCallback((): void => {
|
||||||
const widgetId = query.get('widgetId');
|
const widgetId = query.get('widgetId');
|
||||||
const selectWidget = widgets?.find((e) => e.id === widgetId);
|
const selectWidget = widgets?.find((e) => e.id === widgetId);
|
||||||
@ -690,6 +712,28 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
|
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
|
||||||
|
|
||||||
|
const showSwitchToViewModeButton =
|
||||||
|
enableDrillDown && !isNewDashboard && !!query.get('widgetId');
|
||||||
|
|
||||||
|
const handleSwitchToViewMode = useCallback(() => {
|
||||||
|
if (!query.get('widgetId')) return;
|
||||||
|
const widgetId = query.get('widgetId') || '';
|
||||||
|
const graphType = query.get('graphType') || '';
|
||||||
|
const queryParams = {
|
||||||
|
[QueryParams.expandedWidgetId]: widgetId,
|
||||||
|
[QueryParams.graphType]: graphType,
|
||||||
|
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||||
|
JSON.stringify(currentQuery),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSearch = createQueryParams(queryParams);
|
||||||
|
safeNavigate({
|
||||||
|
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||||
|
search: updatedSearch,
|
||||||
|
});
|
||||||
|
}, [query, safeNavigate, dashboardId, currentQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<div className="edit-header">
|
<div className="edit-header">
|
||||||
@ -706,31 +750,42 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
{isSaveDisabled && (
|
<div className="right-header">
|
||||||
<Button
|
{showSwitchToViewModeButton && (
|
||||||
type="primary"
|
<Button
|
||||||
data-testid="new-widget-save"
|
data-testid="switch-to-view-mode"
|
||||||
loading={updateDashboardMutation.isLoading}
|
disabled={isSaveDisabled || !currentQuery}
|
||||||
disabled={isSaveDisabled}
|
onClick={handleSwitchToViewMode}
|
||||||
onClick={onSaveDashboard}
|
>
|
||||||
className="save-btn"
|
Switch to View Mode
|
||||||
>
|
</Button>
|
||||||
Save Changes
|
)}
|
||||||
</Button>
|
{isSaveDisabled && (
|
||||||
)}
|
<Button
|
||||||
{!isSaveDisabled && (
|
type="primary"
|
||||||
<Button
|
data-testid="new-widget-save"
|
||||||
type="primary"
|
loading={updateDashboardMutation.isLoading}
|
||||||
data-testid="new-widget-save"
|
disabled={isSaveDisabled}
|
||||||
loading={updateDashboardMutation.isLoading}
|
onClick={onSaveDashboard}
|
||||||
disabled={isSaveDisabled}
|
className="save-btn"
|
||||||
onClick={onSaveDashboard}
|
>
|
||||||
icon={<Check size={14} />}
|
Save Changes
|
||||||
className="save-btn"
|
</Button>
|
||||||
>
|
)}
|
||||||
Save Changes
|
{!isSaveDisabled && (
|
||||||
</Button>
|
<Button
|
||||||
)}
|
type="primary"
|
||||||
|
data-testid="new-widget-save"
|
||||||
|
loading={updateDashboardMutation.isLoading}
|
||||||
|
disabled={isSaveDisabled}
|
||||||
|
onClick={onSaveDashboard}
|
||||||
|
icon={<Check size={14} />}
|
||||||
|
className="save-btn"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PanelContainer>
|
<PanelContainer>
|
||||||
@ -749,6 +804,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
isLoadingPanelData={isLoadingPanelData}
|
isLoadingPanelData={isLoadingPanelData}
|
||||||
setQueryResponse={setQueryResponse}
|
setQueryResponse={setQueryResponse}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</OverlayScrollbar>
|
</OverlayScrollbar>
|
||||||
@ -799,6 +855,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
setSoftMin={setSoftMin}
|
setSoftMin={setSoftMin}
|
||||||
softMax={softMax}
|
softMax={softMax}
|
||||||
setSoftMax={setSoftMax}
|
setSoftMax={setSoftMax}
|
||||||
|
contextLinks={contextLinks}
|
||||||
|
setContextLinks={setContextLinks}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</OverlayScrollbar>
|
</OverlayScrollbar>
|
||||||
</RightContainerWrapper>
|
</RightContainerWrapper>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export interface NewWidgetProps {
|
|||||||
selectedGraph: PANEL_TYPES;
|
selectedGraph: PANEL_TYPES;
|
||||||
yAxisUnit: Widgets['yAxisUnit'];
|
yAxisUnit: Widgets['yAxisUnit'];
|
||||||
fillSpans: Widgets['fillSpans'];
|
fillSpans: Widgets['fillSpans'];
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetGraphProps {
|
export interface WidgetGraphProps {
|
||||||
@ -32,6 +33,7 @@ export interface WidgetGraphProps {
|
|||||||
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
|
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WidgetGraphContainerProps = {
|
export type WidgetGraphContainerProps = {
|
||||||
@ -45,4 +47,5 @@ export type WidgetGraphContainerProps = {
|
|||||||
selectedGraph: PANEL_TYPES;
|
selectedGraph: PANEL_TYPES;
|
||||||
selectedWidget: Widgets;
|
selectedWidget: Widgets;
|
||||||
isLoadingPanelData: boolean;
|
isLoadingPanelData: boolean;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,12 +2,15 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
|||||||
import Uplot from 'components/Uplot';
|
import Uplot from 'components/Uplot';
|
||||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||||
|
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useResizeObserver } from 'hooks/useDimensions';
|
import { useResizeObserver } from 'hooks/useDimensions';
|
||||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||||
import _noop from 'lodash-es/noop';
|
import _noop from 'lodash-es/noop';
|
||||||
|
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { buildHistogramData } from './histogram';
|
import { buildHistogramData } from './histogram';
|
||||||
import { PanelWrapperProps } from './panelWrapper.types';
|
import { PanelWrapperProps } from './panelWrapper.types';
|
||||||
@ -20,11 +23,61 @@ function HistogramPanelWrapper({
|
|||||||
isFullViewMode,
|
isFullViewMode,
|
||||||
onToggleModelHandler,
|
onToggleModelHandler,
|
||||||
onClickHandler,
|
onClickHandler,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const graphRef = useRef<HTMLDivElement>(null);
|
const graphRef = useRef<HTMLDivElement>(null);
|
||||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
const containerDimensions = useResizeObserver(graphRef);
|
const containerDimensions = useResizeObserver(graphRef);
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget.id || '',
|
||||||
|
query: widget.query,
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks: widget.contextLinks,
|
||||||
|
panelType: widget.panelTypes,
|
||||||
|
queryRange: queryResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clickHandlerWithContextMenu = useCallback(
|
||||||
|
(...args: any[]) => {
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
,
|
||||||
|
focusedSeries,
|
||||||
|
] = args;
|
||||||
|
const data = getUplotClickData({
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
focusedSeries,
|
||||||
|
});
|
||||||
|
if (data && data?.record?.queryName) {
|
||||||
|
onClick(data.coord, { ...data.record, label: data.label });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick],
|
||||||
|
);
|
||||||
|
|
||||||
const histogramData = buildHistogramData(
|
const histogramData = buildHistogramData(
|
||||||
queryResponse.data?.payload.data.result,
|
queryResponse.data?.payload.data.result,
|
||||||
@ -73,7 +126,9 @@ function HistogramPanelWrapper({
|
|||||||
setGraphsVisibilityStates: setGraphVisibility,
|
setGraphsVisibilityStates: setGraphVisibility,
|
||||||
graphsVisibilityStates: graphVisibility,
|
graphsVisibilityStates: graphVisibility,
|
||||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||||
onClickHandler: onClickHandler || _noop,
|
onClickHandler: enableDrillDown
|
||||||
|
? clickHandlerWithContextMenu
|
||||||
|
: onClickHandler ?? _noop,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
containerDimensions,
|
containerDimensions,
|
||||||
@ -85,6 +140,8 @@ function HistogramPanelWrapper({
|
|||||||
widget.id,
|
widget.id,
|
||||||
widget.mergeAllActiveQueries,
|
widget.mergeAllActiveQueries,
|
||||||
widget.panelTypes,
|
widget.panelTypes,
|
||||||
|
clickHandlerWithContextMenu,
|
||||||
|
enableDrillDown,
|
||||||
onClickHandler,
|
onClickHandler,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -92,6 +149,13 @@ function HistogramPanelWrapper({
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||||
<GraphManager
|
<GraphManager
|
||||||
data={histogramData}
|
data={histogramData}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ function PanelWrapper({
|
|||||||
onOpenTraceBtnClick,
|
onOpenTraceBtnClick,
|
||||||
customSeries,
|
customSeries,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const Component = PanelTypeVsPanelWrapper[
|
const Component = PanelTypeVsPanelWrapper[
|
||||||
selectedGraph || widget.panelTypes
|
selectedGraph || widget.panelTypes
|
||||||
@ -49,6 +50,7 @@ function PanelWrapper({
|
|||||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
customSeries={customSeries}
|
customSeries={customSeries}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import { Pie } from '@visx/shape';
|
|||||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
import { themeColors } from 'constants/theme';
|
import { themeColors } from 'constants/theme';
|
||||||
|
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import getLabelName from 'lib/getLabelName';
|
import getLabelName from 'lib/getLabelName';
|
||||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import { isNaN } from 'lodash-es';
|
import { isNaN } from 'lodash-es';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||||
@ -19,6 +22,7 @@ import { lightenColor, tooltipStyles } from './utils';
|
|||||||
function PiePanelWrapper({
|
function PiePanelWrapper({
|
||||||
queryResponse,
|
queryResponse,
|
||||||
widget,
|
widget,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const [active, setActive] = useState<{
|
const [active, setActive] = useState<{
|
||||||
label: string;
|
label: string;
|
||||||
@ -48,6 +52,7 @@ function PiePanelWrapper({
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
record: any;
|
||||||
}[] = [].concat(
|
}[] = [].concat(
|
||||||
...(panelData
|
...(panelData
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
@ -55,6 +60,7 @@ function PiePanelWrapper({
|
|||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
value: d?.values?.[0]?.[1],
|
value: d?.values?.[0]?.[1],
|
||||||
|
record: d,
|
||||||
color:
|
color:
|
||||||
widget?.customLegendColors?.[label] ||
|
widget?.customLegendColors?.[label] ||
|
||||||
generateColor(
|
generateColor(
|
||||||
@ -142,6 +148,29 @@ function PiePanelWrapper({
|
|||||||
return active.color === color ? color : lightenedColor;
|
return active.color === color ? color : lightenedColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget.id || '',
|
||||||
|
query: widget.query,
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks: widget.contextLinks,
|
||||||
|
panelType: widget.panelTypes,
|
||||||
|
queryRange: queryResponse,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="piechart-wrapper">
|
<div className="piechart-wrapper">
|
||||||
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
|
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
|
||||||
@ -165,7 +194,7 @@ function PiePanelWrapper({
|
|||||||
height={size}
|
height={size}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, sonarjs/cognitive-complexity
|
||||||
(pie) =>
|
(pie) =>
|
||||||
pie.arcs.map((arc) => {
|
pie.arcs.map((arc) => {
|
||||||
const { label } = arc.data;
|
const { label } = arc.data;
|
||||||
@ -226,6 +255,17 @@ function PiePanelWrapper({
|
|||||||
hideTooltip();
|
hideTooltip();
|
||||||
setActive(null);
|
setActive(null);
|
||||||
}}
|
}}
|
||||||
|
onClick={(e): void => {
|
||||||
|
if (enableDrillDown) {
|
||||||
|
const data = getPieChartClickData(arc);
|
||||||
|
if (data && data?.queryName) {
|
||||||
|
onClick(
|
||||||
|
{ x: e.clientX, y: e.clientY },
|
||||||
|
{ ...data, label: data.label },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
|
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
|
||||||
|
|
||||||
@ -284,6 +324,13 @@ function PiePanelWrapper({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Add total value in the center */}
|
{/* Add total value in the center */}
|
||||||
<text
|
<text
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import GridTableComponent from 'container/GridTableComponent';
|
import GridTableComponent from 'container/GridTableComponent';
|
||||||
import { GRID_TABLE_CONFIG } from 'container/GridTableComponent/config';
|
import { GRID_TABLE_CONFIG } from 'container/GridTableComponent/config';
|
||||||
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
import { PanelWrapperProps } from './panelWrapper.types';
|
import { PanelWrapperProps } from './panelWrapper.types';
|
||||||
|
|
||||||
@ -12,10 +13,14 @@ function TablePanelWrapper({
|
|||||||
openTracesButton,
|
openTracesButton,
|
||||||
onOpenTraceBtnClick,
|
onOpenTraceBtnClick,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const panelData =
|
const panelData =
|
||||||
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
||||||
const { thresholds } = widget;
|
const { thresholds } = widget;
|
||||||
|
|
||||||
|
const queryRangeRequest = queryResponse.data?.params as QueryRangeRequestV5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridTableComponent
|
<GridTableComponent
|
||||||
data={panelData}
|
data={panelData}
|
||||||
@ -31,6 +36,10 @@ function TablePanelWrapper({
|
|||||||
widgetId={widget.id}
|
widgetId={widget.id}
|
||||||
renderColumnCell={widget.renderColumnCell}
|
renderColumnCell={widget.renderColumnCell}
|
||||||
customColTitles={widget.customColTitles}
|
customColTitles={widget.customColTitles}
|
||||||
|
contextLinks={widget.contextLinks}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
|
panelType={widget.panelTypes}
|
||||||
|
queryRangeRequest={queryRangeRequest}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...GRID_TABLE_CONFIG}
|
{...GRID_TABLE_CONFIG}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Uplot from 'components/Uplot';
|
|||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||||
|
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useResizeObserver } from 'hooks/useDimensions';
|
import { useResizeObserver } from 'hooks/useDimensions';
|
||||||
@ -13,14 +15,17 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
|||||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||||
import _noop from 'lodash-es/noop';
|
import _noop from 'lodash-es/noop';
|
||||||
|
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useTimezone } from 'providers/Timezone';
|
import { useTimezone } from 'providers/Timezone';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||||
import { getTimeRange } from 'utils/getTimeRange';
|
import { getTimeRange } from 'utils/getTimeRange';
|
||||||
|
|
||||||
import { PanelWrapperProps } from './panelWrapper.types';
|
import { PanelWrapperProps } from './panelWrapper.types';
|
||||||
|
import { getTimeRangeFromStepInterval, isApmMetric } from './utils';
|
||||||
|
|
||||||
function UplotPanelWrapper({
|
function UplotPanelWrapper({
|
||||||
queryResponse,
|
queryResponse,
|
||||||
@ -34,6 +39,7 @@ function UplotPanelWrapper({
|
|||||||
selectedGraph,
|
selectedGraph,
|
||||||
customTooltipElement,
|
customTooltipElement,
|
||||||
customSeries,
|
customSeries,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
@ -65,6 +71,28 @@ function UplotPanelWrapper({
|
|||||||
|
|
||||||
const containerDimensions = useResizeObserver(graphRef);
|
const containerDimensions = useResizeObserver(graphRef);
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget.id || '',
|
||||||
|
query: widget.query,
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks: widget.contextLinks,
|
||||||
|
panelType: widget.panelTypes,
|
||||||
|
queryRange: queryResponse,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {
|
const {
|
||||||
graphVisibilityStates: localStoredVisibilityState,
|
graphVisibilityStates: localStoredVisibilityState,
|
||||||
@ -114,6 +142,57 @@ function UplotPanelWrapper({
|
|||||||
|
|
||||||
const { timezone } = useTimezone();
|
const { timezone } = useTimezone();
|
||||||
|
|
||||||
|
const clickHandlerWithContextMenu = useCallback(
|
||||||
|
(...args: any[]) => {
|
||||||
|
const [
|
||||||
|
xValue,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
axesData,
|
||||||
|
focusedSeries,
|
||||||
|
] = args;
|
||||||
|
const data = getUplotClickData({
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
focusedSeries,
|
||||||
|
});
|
||||||
|
// Compute time range if needed and if axes data is available
|
||||||
|
let timeRange;
|
||||||
|
if (axesData && queryData?.queryName) {
|
||||||
|
// Get the compositeQuery from the response params
|
||||||
|
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
|
||||||
|
|
||||||
|
if (compositeQuery?.queries) {
|
||||||
|
// Find the specific query by name from the queries array
|
||||||
|
const specificQuery = compositeQuery.queries.find(
|
||||||
|
(query: any) => query.spec?.name === queryData.queryName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the stepInterval from the specific query, fallback to default
|
||||||
|
const stepInterval = specificQuery?.spec?.stepInterval || 60;
|
||||||
|
timeRange = getTimeRangeFromStepInterval(
|
||||||
|
stepInterval,
|
||||||
|
metric?.clickedTimestamp || xValue, // Use the clicked timestamp if available, otherwise use the click position timestamp
|
||||||
|
specificQuery?.spec?.signal === DataSource.METRICS &&
|
||||||
|
isApmMetric(specificQuery?.spec?.aggregations[0]?.metricName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data?.record?.queryName) {
|
||||||
|
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick, queryResponse],
|
||||||
|
);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getUPlotChartOptions({
|
getUPlotChartOptions({
|
||||||
@ -123,7 +202,9 @@ function UplotPanelWrapper({
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
onDragSelect,
|
onDragSelect,
|
||||||
yAxisUnit: widget?.yAxisUnit,
|
yAxisUnit: widget?.yAxisUnit,
|
||||||
onClickHandler: onClickHandler || _noop,
|
onClickHandler: enableDrillDown
|
||||||
|
? clickHandlerWithContextMenu
|
||||||
|
: onClickHandler ?? _noop,
|
||||||
thresholds: widget.thresholds,
|
thresholds: widget.thresholds,
|
||||||
minTimeScale,
|
minTimeScale,
|
||||||
maxTimeScale,
|
maxTimeScale,
|
||||||
@ -152,7 +233,7 @@ function UplotPanelWrapper({
|
|||||||
containerDimensions,
|
containerDimensions,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
onDragSelect,
|
onDragSelect,
|
||||||
onClickHandler,
|
clickHandlerWithContextMenu,
|
||||||
minTimeScale,
|
minTimeScale,
|
||||||
maxTimeScale,
|
maxTimeScale,
|
||||||
graphVisibility,
|
graphVisibility,
|
||||||
@ -163,6 +244,8 @@ function UplotPanelWrapper({
|
|||||||
customTooltipElement,
|
customTooltipElement,
|
||||||
timezone.value,
|
timezone.value,
|
||||||
customSeries,
|
customSeries,
|
||||||
|
enableDrillDown,
|
||||||
|
onClickHandler,
|
||||||
widget,
|
widget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -170,6 +253,13 @@ function UplotPanelWrapper({
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
{widget?.stackedBarChart && isFullViewMode && (
|
{widget?.stackedBarChart && isFullViewMode && (
|
||||||
<Alert
|
<Alert
|
||||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { PanelWrapperProps } from './panelWrapper.types';
|
|||||||
function ValuePanelWrapper({
|
function ValuePanelWrapper({
|
||||||
widget,
|
widget,
|
||||||
queryResponse,
|
queryResponse,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const { yAxisUnit, thresholds } = widget;
|
const { yAxisUnit, thresholds } = widget;
|
||||||
const data = getUPlotChartData(queryResponse?.data?.payload);
|
const data = getUPlotChartData(queryResponse?.data?.payload);
|
||||||
@ -22,6 +23,10 @@ function ValuePanelWrapper({
|
|||||||
data={gridValueData}
|
data={gridValueData}
|
||||||
yAxisUnit={yAxisUnit}
|
yAxisUnit={yAxisUnit}
|
||||||
thresholds={thresholds}
|
thresholds={thresholds}
|
||||||
|
widget={widget}
|
||||||
|
queryResponse={queryResponse}
|
||||||
|
contextLinks={widget.contextLinks}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -266,22 +266,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
demo-app
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
demo-app
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
4.35 s
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
4.35 s
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -292,22 +304,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
customer
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
customer
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
431 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
431 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -318,22 +342,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
mysql
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
mysql
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
431 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
431 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -344,22 +380,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
frontend
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
frontend
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
287 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
287 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -370,22 +418,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
driver
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
driver
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
230 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
230 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -396,22 +456,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
route
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
route
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
66.4 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
66.4 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -422,22 +494,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
redis
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
redis
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
31.3 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
31.3 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -18,6 +18,11 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
|||||||
-webkit-flex-direction: column;
|
-webkit-flex-direction: column;
|
||||||
-ms-flex-direction: column;
|
-ms-flex-direction: column;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c0 {
|
.c0 {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export type PanelWrapperProps = {
|
|||||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TooltipData = {
|
export type TooltipData = {
|
||||||
|
|||||||
@ -71,3 +71,34 @@ export const lightenColor = (color: string, opacity: number): string => {
|
|||||||
// Create a new RGBA color string with the specified opacity
|
// Create a new RGBA color string with the specified opacity
|
||||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTimeRangeFromUplotAxis = (
|
||||||
|
axis: any,
|
||||||
|
xValue: number,
|
||||||
|
): { startTime: number; endTime: number } => {
|
||||||
|
let gap =
|
||||||
|
(axis as any)._splits && (axis as any)._splits.length > 1
|
||||||
|
? (axis as any)._splits[1] - (axis as any)._splits[0]
|
||||||
|
: 600; // 10 minutes in seconds
|
||||||
|
|
||||||
|
gap = Math.max(gap, 600); // Minimum gap of 10 minutes in seconds
|
||||||
|
|
||||||
|
const startTime = xValue - gap;
|
||||||
|
const endTime = xValue + gap;
|
||||||
|
|
||||||
|
return { startTime, endTime };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isApmMetric = (metric = ''): boolean =>
|
||||||
|
// if metric starts with 'signoz_', then it is an apm metric
|
||||||
|
metric.startsWith('signoz_');
|
||||||
|
|
||||||
|
export const getTimeRangeFromStepInterval = (
|
||||||
|
stepInterval: number,
|
||||||
|
xValue: number,
|
||||||
|
isApmMetric: boolean,
|
||||||
|
): { startTime: number; endTime: number } => {
|
||||||
|
const startTime = isApmMetric ? xValue - stepInterval : xValue;
|
||||||
|
const endTime = xValue + stepInterval;
|
||||||
|
return { startTime, endTime };
|
||||||
|
};
|
||||||
|
|||||||
135
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
135
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import './Breakoutoptions.styles.scss';
|
||||||
|
|
||||||
|
import { Input, Skeleton } from 'antd';
|
||||||
|
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||||
|
import useDebounce from 'hooks/useDebounce';
|
||||||
|
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
|
import { BreakoutOptionsProps } from './contextConfig';
|
||||||
|
import { BreakoutAttributeType } from './types';
|
||||||
|
|
||||||
|
function OptionsSkeleton(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="breakout-options-skeleton">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<Skeleton.Input
|
||||||
|
active
|
||||||
|
size="small"
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakoutOptions({
|
||||||
|
queryData,
|
||||||
|
onColumnClick,
|
||||||
|
}: BreakoutOptionsProps): JSX.Element {
|
||||||
|
const { groupBy = [] } = queryData;
|
||||||
|
const [searchText, setSearchText] = useState<string>('');
|
||||||
|
const debouncedSearchText = useDebounce(searchText, 400);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const value = e.target.value.trim().toLowerCase();
|
||||||
|
setSearchText(value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Using getKeySuggestions directly like in QuerySearch
|
||||||
|
const { data, isFetching } = useQuery(
|
||||||
|
[
|
||||||
|
'keySuggestions',
|
||||||
|
queryData.dataSource,
|
||||||
|
debouncedSearchText,
|
||||||
|
queryData.aggregateAttribute?.key,
|
||||||
|
],
|
||||||
|
() =>
|
||||||
|
getKeySuggestions({
|
||||||
|
signal: queryData.dataSource,
|
||||||
|
searchText: debouncedSearchText,
|
||||||
|
metricName:
|
||||||
|
(queryData.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||||
|
queryData.aggregateAttribute?.key,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
enabled: !!queryData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const breakoutOptions = useMemo(() => {
|
||||||
|
if (!data?.data?.data?.keys) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { keys } = data.data.data;
|
||||||
|
const transformedOptions: BreakoutAttributeType[] = [];
|
||||||
|
|
||||||
|
// Transform the response to match BaseAutocompleteData format
|
||||||
|
Object.values(keys).forEach((keyArray) => {
|
||||||
|
keyArray.forEach((keyData) => {
|
||||||
|
transformedOptions.push({
|
||||||
|
key: keyData.name,
|
||||||
|
dataType: keyData.fieldDataType as QUERY_BUILDER_KEY_TYPES,
|
||||||
|
type: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out already selected groupBy keys
|
||||||
|
const groupByKeys = groupBy.map((item: BaseAutocompleteData) => item.key);
|
||||||
|
return transformedOptions.filter(
|
||||||
|
(item: BreakoutAttributeType) => !groupByKeys.includes(item.key),
|
||||||
|
);
|
||||||
|
}, [data, groupBy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section className="search" style={{ padding: '8px 0' }}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={searchText}
|
||||||
|
placeholder="Search breakout options..."
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<div>
|
||||||
|
<OverlayScrollbar
|
||||||
|
style={{ maxHeight: '200px' }}
|
||||||
|
options={{
|
||||||
|
overflow: {
|
||||||
|
x: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
|
||||||
|
<>
|
||||||
|
{isFetching ? (
|
||||||
|
<OptionsSkeleton />
|
||||||
|
) : (
|
||||||
|
breakoutOptions?.map((item: BreakoutAttributeType) => (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={item.key}
|
||||||
|
onClick={(): void => onColumnClick(item)}
|
||||||
|
>
|
||||||
|
{item.key}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</OverlayScrollbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BreakoutOptions;
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
.breakout-options-skeleton {
|
||||||
|
.ant-skeleton-input {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 20px !important;
|
||||||
|
margin: 8px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { rest } from 'msw';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
|
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import store from 'store';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import useTableContextMenu from '../useTableContextMenu';
|
||||||
|
import {
|
||||||
|
MOCK_AGGREGATE_DATA,
|
||||||
|
MOCK_COORDINATES,
|
||||||
|
MOCK_FILTER_DATA,
|
||||||
|
MOCK_KEY_SUGGESTIONS_RESPONSE,
|
||||||
|
// MOCK_KEY_SUGGESTIONS_SEARCH_RESPONSE,
|
||||||
|
MOCK_QUERY,
|
||||||
|
} from './mockTableData';
|
||||||
|
|
||||||
|
// Mock the necessary hooks and dependencies
|
||||||
|
const mockSafeNavigate = jest.fn();
|
||||||
|
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): any => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (): any => ({
|
||||||
|
getUpdatedQuery: jest.fn().mockResolvedValue({}),
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string } => ({
|
||||||
|
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD}/`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-redux', () => ({
|
||||||
|
...jest.requireActual('react-redux'),
|
||||||
|
useSelector: (): any => ({
|
||||||
|
globalTime: {
|
||||||
|
selectedTime: {
|
||||||
|
startTime: 1713734400000,
|
||||||
|
endTime: 1713738000000,
|
||||||
|
},
|
||||||
|
maxTime: 1713738000000,
|
||||||
|
minTime: 1713734400000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('container/QueryTable/Drilldown/useDashboardVarConfig', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (): any => ({
|
||||||
|
dashbaordVariablesConfig: {
|
||||||
|
items: <>items</>,
|
||||||
|
},
|
||||||
|
// contextItems: <></>,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function MockTableDrilldown(): JSX.Element {
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
|
||||||
|
const { menuItemsConfig } = useTableContextMenu({
|
||||||
|
widgetId: 'test-widget',
|
||||||
|
query: MOCK_QUERY as Query,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (type: 'aggregate' | 'filter'): void => {
|
||||||
|
// Simulate the same flow as handleColumnClick in QueryTable
|
||||||
|
onClick(
|
||||||
|
MOCK_COORDINATES,
|
||||||
|
type === 'aggregate' ? MOCK_AGGREGATE_DATA : MOCK_FILTER_DATA,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<Button type="primary" onClick={(): void => handleClick('aggregate')}>
|
||||||
|
Aggregate
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="primary" onClick={(): void => handleClick('filter')}>
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
onClose={onClose}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
title={
|
||||||
|
typeof menuItemsConfig.header === 'string'
|
||||||
|
? menuItemsConfig.header
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderWithProviders = (
|
||||||
|
component: React.ReactElement,
|
||||||
|
): ReturnType<typeof render> =>
|
||||||
|
render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<Provider store={store}>{component}</Provider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('TableDrilldown Breakout Functionality', () => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock the substitute_vars API that's causing network errors
|
||||||
|
server.use(
|
||||||
|
rest.post('*/api/v5/substitute_vars', (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show breakout options when "Breakout by" is clicked', async (): Promise<void> => {
|
||||||
|
// Mock the MSW server to intercept the keySuggestions API call
|
||||||
|
server.use(
|
||||||
|
rest.get('*/fields/keys', (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(MOCK_KEY_SUGGESTIONS_RESPONSE)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the aggregate button to show context menu
|
||||||
|
const aggregateButton = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(aggregateButton);
|
||||||
|
|
||||||
|
// Find and click "Breakout by" option
|
||||||
|
const breakoutOption = screen.getByText(/Breakout by/);
|
||||||
|
fireEvent.click(breakoutOption);
|
||||||
|
|
||||||
|
// Wait for the breakout options to load and verify they are displayed
|
||||||
|
await screen.findByText('Breakout by');
|
||||||
|
|
||||||
|
// Check that the search input is displayed
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText('Search breakout options...'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Wait for the API call to complete and options to load
|
||||||
|
// Check what's actually being rendered instead of waiting for specific text
|
||||||
|
await screen.findByText('deployment.environment');
|
||||||
|
|
||||||
|
// Check that the breakout options are loaded and displayed
|
||||||
|
// Based on the test output, these are the actual options being rendered
|
||||||
|
expect(screen.getByText('deployment.environment')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('http.status_code')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify that the breakout header is displayed
|
||||||
|
expect(screen.getByText('Breakout by')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add selected breakout option to groupBy and redirect with correct query', async (): Promise<void> => {
|
||||||
|
// Mock the MSW server to intercept the keySuggestions API call
|
||||||
|
server.use(
|
||||||
|
rest.get('*/fields/keys', (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(MOCK_KEY_SUGGESTIONS_RESPONSE)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Navigate to breakout options
|
||||||
|
const aggregateButton = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(aggregateButton);
|
||||||
|
|
||||||
|
const breakoutOption = screen.getByText(/Breakout by/);
|
||||||
|
fireEvent.click(breakoutOption);
|
||||||
|
|
||||||
|
// Wait for breakout options to load
|
||||||
|
await screen.findByText('deployment.environment');
|
||||||
|
|
||||||
|
// Click on a breakout option (e.g., deployment.environment)
|
||||||
|
const breakoutOptionItem = screen.getByText('deployment.environment');
|
||||||
|
fireEvent.click(breakoutOptionItem);
|
||||||
|
|
||||||
|
// Verify redirectWithQueryBuilderData was called
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [
|
||||||
|
query,
|
||||||
|
queryParams,
|
||||||
|
,
|
||||||
|
newTab,
|
||||||
|
] = mockRedirectWithQueryBuilderData.mock.calls[0];
|
||||||
|
|
||||||
|
// Check that the query contains the correct structure
|
||||||
|
expect(query.builder).toBeDefined();
|
||||||
|
expect(query.builder.queryData).toBeDefined();
|
||||||
|
|
||||||
|
// Find the query data for the aggregate query (queryName: 'A')
|
||||||
|
const aggregateQueryData = query.builder.queryData.find(
|
||||||
|
(item: any) => item.queryName === 'A',
|
||||||
|
);
|
||||||
|
expect(aggregateQueryData).toBeDefined();
|
||||||
|
|
||||||
|
// Verify that the groupBy has been updated to only contain the selected breakout option
|
||||||
|
expect(aggregateQueryData.groupBy).toHaveLength(1);
|
||||||
|
expect(aggregateQueryData.groupBy[0].key).toEqual('deployment.environment');
|
||||||
|
|
||||||
|
// Verify that orderBy has been cleared (as per getBreakoutQuery logic)
|
||||||
|
expect(aggregateQueryData.orderBy).toEqual([]);
|
||||||
|
|
||||||
|
// Verify that the legend has been updated (check the actual value being returned)
|
||||||
|
// The legend logic in getBreakoutQuery: legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : ''
|
||||||
|
// Since the original legend might be empty, the result could be empty string
|
||||||
|
expect(aggregateQueryData.legend).toBeDefined();
|
||||||
|
|
||||||
|
// Check that the queryParams contain the expandedWidgetId
|
||||||
|
expect(queryParams).toEqual({
|
||||||
|
expandedWidgetId: 'test-widget',
|
||||||
|
graphType: 'graph',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that newTab is true
|
||||||
|
expect(newTab).toBe(true);
|
||||||
|
|
||||||
|
// Verify that the original filters are preserved and new filters are added
|
||||||
|
expect(aggregateQueryData.filter.expression).toContain(
|
||||||
|
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env'",
|
||||||
|
);
|
||||||
|
// The new filter from the clicked data should also be present
|
||||||
|
expect(aggregateQueryData.filter.expression).toContain(
|
||||||
|
"service.name = 'adservice' AND trace_id = 'df2cfb0e57bb8736207689851478cd50'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,385 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
|
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import store from 'store';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
|
import useTableContextMenu from '../useTableContextMenu';
|
||||||
|
import {
|
||||||
|
MOCK_AGGREGATE_DATA,
|
||||||
|
MOCK_COORDINATES,
|
||||||
|
MOCK_FILTER_DATA,
|
||||||
|
MOCK_QUERY,
|
||||||
|
MOCK_QUERY_RANGE_REQUEST,
|
||||||
|
MOCK_QUERY_WITH_FILTER,
|
||||||
|
} from './mockTableData';
|
||||||
|
|
||||||
|
// Mock the necessary hooks and dependencies
|
||||||
|
const mockSafeNavigate = jest.fn();
|
||||||
|
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): any => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string } => ({
|
||||||
|
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD}/`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-redux', () => ({
|
||||||
|
...jest.requireActual('react-redux'),
|
||||||
|
useSelector: (): any => ({
|
||||||
|
globalTime: {
|
||||||
|
selectedTime: {
|
||||||
|
startTime: 1713734400000,
|
||||||
|
endTime: 1713738000000,
|
||||||
|
},
|
||||||
|
maxTime: 1713738000000,
|
||||||
|
minTime: 1713734400000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('container/QueryTable/Drilldown/useDashboardVarConfig', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (): any => ({
|
||||||
|
dashbaordVariablesConfig: {
|
||||||
|
items: <>items</>,
|
||||||
|
},
|
||||||
|
// contextItems: <></>,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function MockTableDrilldown(): JSX.Element {
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
|
||||||
|
const { menuItemsConfig } = useTableContextMenu({
|
||||||
|
widgetId: 'test-widget',
|
||||||
|
query: MOCK_QUERY as Query,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
queryRangeRequest: MOCK_QUERY_RANGE_REQUEST as QueryRangeRequestV5,
|
||||||
|
contextLinks: { linksData: [] }, // Provide empty context links to allow data links to render
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (type: 'aggregate' | 'filter'): void => {
|
||||||
|
// Simulate the same flow as handleColumnClick in QueryTable
|
||||||
|
onClick(
|
||||||
|
MOCK_COORDINATES,
|
||||||
|
type === 'aggregate' ? MOCK_AGGREGATE_DATA : MOCK_FILTER_DATA,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<Button type="primary" onClick={(): void => handleClick('aggregate')}>
|
||||||
|
Aggregate
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="primary" onClick={(): void => handleClick('filter')}>
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
onClose={onClose}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
title={
|
||||||
|
typeof menuItemsConfig.header === 'string'
|
||||||
|
? menuItemsConfig.header
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderWithProviders = (
|
||||||
|
component: React.ReactElement,
|
||||||
|
): ReturnType<typeof render> =>
|
||||||
|
render(
|
||||||
|
<MockQueryClientProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<Provider store={store}>{component}</Provider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MockQueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('TableDrilldown', () => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show context menu filter options when button is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button
|
||||||
|
const button = screen.getByRole('button', { name: /filter/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Check that the context menu options are displayed
|
||||||
|
expect(screen.getByText('Filter by trace_id')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show context menu aggregate options when button is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button
|
||||||
|
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Check that the context menu options are displayed
|
||||||
|
expect(screen.getByText('logs')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('count()')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('View in Logs')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('View in Traces')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Breakout by/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to logs explorer with correct query when "View in Logs" is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button to show context menu
|
||||||
|
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Find and click "View in Logs" option
|
||||||
|
const viewInLogsOption = screen.getByText('View in Logs');
|
||||||
|
fireEvent.click(viewInLogsOption);
|
||||||
|
|
||||||
|
// Verify safeNavigate was called with the correct URL
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [url, options] = mockSafeNavigate.mock.calls[0];
|
||||||
|
|
||||||
|
// Check the URL structure
|
||||||
|
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||||
|
expect(url).toContain('?');
|
||||||
|
|
||||||
|
// Parse the URL to check query parameters
|
||||||
|
const urlObj = new URL(url, 'http://localhost');
|
||||||
|
|
||||||
|
// Check that compositeQuery parameter exists and contains the query with filters
|
||||||
|
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||||
|
|
||||||
|
const compositeQuery = JSON.parse(
|
||||||
|
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the query structure includes the filters from clicked data
|
||||||
|
expect(compositeQuery.builder).toBeDefined();
|
||||||
|
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||||
|
|
||||||
|
// Check that the query contains the correct filter expression
|
||||||
|
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
|
||||||
|
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||||
|
expect(firstQueryData.filters).toBeDefined();
|
||||||
|
|
||||||
|
// Check that newTab option is set to true
|
||||||
|
expect(options).toEqual({ newTab: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include timestamps in logs explorer URL when "View in Logs" is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button to show context menu
|
||||||
|
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Find and click "View in Logs" option
|
||||||
|
const viewInLogsOption = screen.getByText('View in Logs');
|
||||||
|
fireEvent.click(viewInLogsOption);
|
||||||
|
|
||||||
|
// Verify safeNavigate was called with the correct URL
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [url] = mockSafeNavigate.mock.calls[0];
|
||||||
|
|
||||||
|
// Parse the URL to check query parameters
|
||||||
|
const urlObj = new URL(url, 'http://localhost');
|
||||||
|
|
||||||
|
// Check that timestamp parameters exist and have correct values
|
||||||
|
expect(urlObj.searchParams.has('startTime')).toBe(true);
|
||||||
|
expect(urlObj.searchParams.has('endTime')).toBe(true);
|
||||||
|
|
||||||
|
// Verify the timestamp values match the mock query range request
|
||||||
|
// MOCK_QUERY_RANGE_REQUEST has start: 1756972732000, end: 1756974532000
|
||||||
|
// These should be converted to seconds in the URL (divided by 1000)
|
||||||
|
const expectedStartTime = Math.floor(1756972732000 / 1000).toString();
|
||||||
|
const expectedEndTime = Math.floor(1756974532000 / 1000).toString();
|
||||||
|
|
||||||
|
expect(urlObj.searchParams.get('startTime')).toBe(expectedStartTime);
|
||||||
|
expect(urlObj.searchParams.get('endTime')).toBe(expectedEndTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to traces explorer with correct query when "View in Traces" is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button to show context menu
|
||||||
|
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Find and click "View in Traces" option
|
||||||
|
const viewInTracesOption = screen.getByText('View in Traces');
|
||||||
|
fireEvent.click(viewInTracesOption);
|
||||||
|
|
||||||
|
// Verify safeNavigate was called with the correct URL
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [url, options] = mockSafeNavigate.mock.calls[0];
|
||||||
|
|
||||||
|
// Check the URL structure
|
||||||
|
expect(url).toContain(ROUTES.TRACES_EXPLORER);
|
||||||
|
expect(url).toContain('?');
|
||||||
|
|
||||||
|
// Parse the URL to check query parameters
|
||||||
|
const urlObj = new URL(url, 'http://localhost');
|
||||||
|
|
||||||
|
// Check that compositeQuery parameter exists and contains the query with filters
|
||||||
|
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||||
|
|
||||||
|
const compositeQuery = JSON.parse(
|
||||||
|
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||||
|
);
|
||||||
|
// Verify the query structure includes the filters from clicked data
|
||||||
|
expect(compositeQuery.builder).toBeDefined();
|
||||||
|
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||||
|
|
||||||
|
// Check that the query contains the correct filter expression
|
||||||
|
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
|
||||||
|
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||||
|
expect(firstQueryData.filter.expression).toEqual(MOCK_QUERY_WITH_FILTER);
|
||||||
|
|
||||||
|
// Check that newTab option is set to true
|
||||||
|
expect(options).toEqual({ newTab: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include timestamps in traces explorer URL when "View in Traces" is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button to show context menu
|
||||||
|
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Find and click "View in Traces" option
|
||||||
|
const viewInTracesOption = screen.getByText('View in Traces');
|
||||||
|
fireEvent.click(viewInTracesOption);
|
||||||
|
|
||||||
|
// Verify safeNavigate was called with the correct URL
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [url] = mockSafeNavigate.mock.calls[0];
|
||||||
|
|
||||||
|
// Parse the URL to check query parameters
|
||||||
|
const urlObj = new URL(url, 'http://localhost');
|
||||||
|
|
||||||
|
// Check that timestamp parameters exist and have correct values
|
||||||
|
expect(urlObj.searchParams.has('startTime')).toBe(true);
|
||||||
|
expect(urlObj.searchParams.has('endTime')).toBe(true);
|
||||||
|
|
||||||
|
// Verify the timestamp values match the mock query range request
|
||||||
|
// MOCK_QUERY_RANGE_REQUEST has start: 1756972732000, end: 1756974532000
|
||||||
|
// These should be converted to seconds in the URL (divided by 1000)
|
||||||
|
const expectedStartTime = Math.floor(1756972732000 / 1000).toString();
|
||||||
|
const expectedEndTime = Math.floor(1756974532000 / 1000).toString();
|
||||||
|
|
||||||
|
expect(urlObj.searchParams.get('startTime')).toBe(expectedStartTime);
|
||||||
|
expect(urlObj.searchParams.get('endTime')).toBe(expectedEndTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show filter options and navigate with correct query when filter option is clicked', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the Filter button to show filter context menu
|
||||||
|
const filterButton = screen.getByRole('button', { name: /filter/i });
|
||||||
|
fireEvent.click(filterButton);
|
||||||
|
|
||||||
|
// Check that the filter context menu is displayed
|
||||||
|
expect(screen.getByText('Filter by trace_id')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that the filter operators are displayed
|
||||||
|
expect(screen.getByText('Is this')).toBeInTheDocument(); // = operator
|
||||||
|
expect(screen.getByText('Is not this')).toBeInTheDocument(); // != operator
|
||||||
|
|
||||||
|
// Click on "Is this" (equals operator)
|
||||||
|
const equalsOption = screen.getByText('Is this');
|
||||||
|
fireEvent.click(equalsOption);
|
||||||
|
|
||||||
|
// Verify redirectWithQueryBuilderData was called instead of safeNavigate
|
||||||
|
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [
|
||||||
|
query,
|
||||||
|
queryParams,
|
||||||
|
,
|
||||||
|
newTab,
|
||||||
|
] = mockRedirectWithQueryBuilderData.mock.calls[0];
|
||||||
|
|
||||||
|
// Check that the query contains the filter that was added
|
||||||
|
expect(query.builder).toBeDefined();
|
||||||
|
expect(query.builder.queryData).toBeDefined();
|
||||||
|
|
||||||
|
const firstQueryData = query.builder.queryData[0];
|
||||||
|
|
||||||
|
// The filter should include the original filter plus the new one from clicked data
|
||||||
|
// Original: "service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env'"
|
||||||
|
// New: trace_id = 'df2cfb0e57bb8736207689851478cd50'
|
||||||
|
expect(firstQueryData.filter.expression).toContain(
|
||||||
|
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env'",
|
||||||
|
);
|
||||||
|
expect(firstQueryData.filter.expression).toContain(
|
||||||
|
"trace_id = 'df2cfb0e57bb8736207689851478cd50'",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the queryParams contain the expandedWidgetId
|
||||||
|
expect(queryParams).toEqual({ expandedWidgetId: 'test-widget' });
|
||||||
|
|
||||||
|
// Check that newTab is true
|
||||||
|
expect(newTab).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "View Trace Details" link when aggregate data contains trace_id filter', (): void => {
|
||||||
|
renderWithProviders(<MockTableDrilldown />);
|
||||||
|
|
||||||
|
// Find and click the button to show context menu
|
||||||
|
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Check that the "View Trace Details" link is displayed
|
||||||
|
// This should appear because MOCK_AGGREGATE_DATA contains trace_id: 'df2cfb0e57bb8736207689851478cd50'
|
||||||
|
expect(screen.getByText('View Trace Details')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MockTableDrilldown;
|
||||||
@ -0,0 +1,448 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
/* eslint-disable sonarjs/no-identical-functions */
|
||||||
|
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getQueryData,
|
||||||
|
getViewQuery,
|
||||||
|
isValidQueryName,
|
||||||
|
} from '../drilldownUtils';
|
||||||
|
|
||||||
|
// Mock the transformMetricsToLogsTraces function since it's not exported
|
||||||
|
// We'll test it indirectly through getViewQuery
|
||||||
|
describe('drilldownUtils', () => {
|
||||||
|
describe('getQueryData', () => {
|
||||||
|
it('should return the first query that matches the queryName', () => {
|
||||||
|
const mockQuery: Query = {
|
||||||
|
id: 'test-query',
|
||||||
|
queryType: 'builder' as any,
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
queryName: 'query1',
|
||||||
|
dataSource: 'metrics' as any,
|
||||||
|
groupBy: [],
|
||||||
|
expression: '',
|
||||||
|
disabled: false,
|
||||||
|
functions: [],
|
||||||
|
legend: '',
|
||||||
|
having: [],
|
||||||
|
limit: null,
|
||||||
|
stepInterval: undefined,
|
||||||
|
orderBy: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryName: 'query2',
|
||||||
|
dataSource: 'logs' as any,
|
||||||
|
groupBy: [],
|
||||||
|
expression: '',
|
||||||
|
disabled: false,
|
||||||
|
functions: [],
|
||||||
|
legend: '',
|
||||||
|
having: [],
|
||||||
|
limit: null,
|
||||||
|
stepInterval: undefined,
|
||||||
|
orderBy: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
promql: [],
|
||||||
|
clickhouse_sql: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getQueryData(mockQuery, 'query2');
|
||||||
|
expect(result?.queryName).toBe('query2');
|
||||||
|
expect(result?.dataSource).toBe('logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if no query matches the queryName', () => {
|
||||||
|
const mockQuery: Query = {
|
||||||
|
id: 'test-query',
|
||||||
|
queryType: 'builder' as any,
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
queryName: 'query1',
|
||||||
|
dataSource: 'metrics' as any,
|
||||||
|
groupBy: [],
|
||||||
|
expression: '',
|
||||||
|
disabled: false,
|
||||||
|
functions: [],
|
||||||
|
legend: '',
|
||||||
|
having: [],
|
||||||
|
limit: null,
|
||||||
|
stepInterval: undefined,
|
||||||
|
orderBy: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
promql: [],
|
||||||
|
clickhouse_sql: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getQueryData(mockQuery, 'nonexistent');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidQueryName', () => {
|
||||||
|
it('should return false for empty queryName', () => {
|
||||||
|
expect(isValidQueryName('')).toBe(false);
|
||||||
|
expect(isValidQueryName(' ')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for queryName starting with F', () => {
|
||||||
|
expect(isValidQueryName('F1')).toBe(false);
|
||||||
|
expect(isValidQueryName('Formula1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for valid queryName', () => {
|
||||||
|
expect(isValidQueryName('query1')).toBe(true);
|
||||||
|
expect(isValidQueryName('metrics_query')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getViewQuery with metric-to-logs/traces transformations', () => {
|
||||||
|
// Mock data for testing transformations
|
||||||
|
const mockMetricsQuery: Query = {
|
||||||
|
id: 'metrics-query',
|
||||||
|
queryType: 'builder' as any,
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
queryName: 'metrics_query',
|
||||||
|
dataSource: 'metrics' as any,
|
||||||
|
groupBy: [],
|
||||||
|
expression: '',
|
||||||
|
disabled: false,
|
||||||
|
functions: [],
|
||||||
|
legend: '',
|
||||||
|
having: [],
|
||||||
|
limit: null,
|
||||||
|
stepInterval: undefined,
|
||||||
|
orderBy: [],
|
||||||
|
filter: {
|
||||||
|
expression:
|
||||||
|
'operation = "GET" AND span.kind = SPAN_KIND_SERVER AND status.code = STATUS_CODE_OK',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
promql: [],
|
||||||
|
clickhouse_sql: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFilters = [
|
||||||
|
{ filterKey: 'service', filterValue: 'test-service', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should transform metrics query when drilling down to logs', () => {
|
||||||
|
const result = getViewQuery(
|
||||||
|
mockMetricsQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.builder.queryData).toHaveLength(1);
|
||||||
|
|
||||||
|
// Check if the filter expression was transformed
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
expect(filterExpression).toBeDefined();
|
||||||
|
|
||||||
|
// Verify transformations were applied
|
||||||
|
if (filterExpression) {
|
||||||
|
// Rule 2: operation → name
|
||||||
|
expect(filterExpression).toContain('name = "GET"');
|
||||||
|
expect(filterExpression).not.toContain('operation = "GET"');
|
||||||
|
|
||||||
|
// Rule 3: span.kind → kind
|
||||||
|
expect(filterExpression).toContain('kind = 2');
|
||||||
|
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
|
||||||
|
|
||||||
|
// Rule 4: status.code → status_code_string with value mapping
|
||||||
|
expect(filterExpression).toContain('status_code_string = Ok');
|
||||||
|
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform metrics query when drilling down to traces', () => {
|
||||||
|
const result = getViewQuery(
|
||||||
|
mockMetricsQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_traces',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.builder.queryData).toHaveLength(1);
|
||||||
|
|
||||||
|
// Check if the filter expression was transformed
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
expect(filterExpression).toBeDefined();
|
||||||
|
|
||||||
|
// Verify transformations were applied
|
||||||
|
if (filterExpression) {
|
||||||
|
// Rule 2: operation → name
|
||||||
|
expect(filterExpression).toContain('name = "GET"');
|
||||||
|
expect(filterExpression).not.toContain('operation = "GET"');
|
||||||
|
|
||||||
|
// Rule 3: span.kind → kind
|
||||||
|
expect(filterExpression).toContain('kind = 2');
|
||||||
|
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
|
||||||
|
|
||||||
|
// Rule 4: status.code → status_code_string with value mapping
|
||||||
|
expect(filterExpression).toContain('status_code_string = Ok');
|
||||||
|
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT transform metrics query when drilling down to metrics', () => {
|
||||||
|
const result = getViewQuery(
|
||||||
|
mockMetricsQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_metrics',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.builder.queryData).toHaveLength(1);
|
||||||
|
|
||||||
|
// Check that the filter expression was NOT transformed
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
expect(filterExpression).toBeDefined();
|
||||||
|
|
||||||
|
// Verify NO transformations were applied
|
||||||
|
if (filterExpression) {
|
||||||
|
// Should still contain original metric format
|
||||||
|
expect(filterExpression).toContain('operation = "GET"');
|
||||||
|
expect(filterExpression).toContain('span.kind = SPAN_KIND_SERVER');
|
||||||
|
expect(filterExpression).toContain('status.code = STATUS_CODE_OK');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex filter expressions with multiple transformations', () => {
|
||||||
|
const complexQuery: Query = {
|
||||||
|
...mockMetricsQuery,
|
||||||
|
builder: {
|
||||||
|
...mockMetricsQuery.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...mockMetricsQuery.builder.queryData[0],
|
||||||
|
filter: {
|
||||||
|
expression:
|
||||||
|
'operation = "POST" AND span.kind = SPAN_KIND_CLIENT AND status.code = STATUS_CODE_ERROR AND http.status_code = 500',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getViewQuery(
|
||||||
|
complexQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
|
||||||
|
if (filterExpression) {
|
||||||
|
// All transformations should be applied
|
||||||
|
expect(filterExpression).toContain('name = "POST"');
|
||||||
|
expect(filterExpression).toContain('kind = 3');
|
||||||
|
expect(filterExpression).toContain('status_code_string = Error');
|
||||||
|
expect(filterExpression).toContain('http.status_code = 500');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle filter expressions with no transformations needed', () => {
|
||||||
|
const simpleQuery: Query = {
|
||||||
|
...mockMetricsQuery,
|
||||||
|
builder: {
|
||||||
|
...mockMetricsQuery.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...mockMetricsQuery.builder.queryData[0],
|
||||||
|
filter: {
|
||||||
|
expression: 'service = "test-service" AND method = "GET"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getViewQuery(
|
||||||
|
simpleQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
|
||||||
|
if (filterExpression) {
|
||||||
|
// No transformations should be applied
|
||||||
|
expect(filterExpression).toContain('service = "test-service"');
|
||||||
|
expect(filterExpression).toContain('method = "GET"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all status code value mappings correctly', () => {
|
||||||
|
const statusCodeTests = [
|
||||||
|
{ input: 'STATUS_CODE_UNSET', expected: 'Unset' },
|
||||||
|
{ input: 'STATUS_CODE_OK', expected: 'Ok' },
|
||||||
|
{ input: 'STATUS_CODE_ERROR', expected: 'Error' },
|
||||||
|
];
|
||||||
|
|
||||||
|
statusCodeTests.forEach(({ input, expected }) => {
|
||||||
|
const testQuery: Query = {
|
||||||
|
...mockMetricsQuery,
|
||||||
|
builder: {
|
||||||
|
...mockMetricsQuery.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...mockMetricsQuery.builder.queryData[0],
|
||||||
|
filter: {
|
||||||
|
expression: `status.code = ${input}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getViewQuery(
|
||||||
|
testQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
|
||||||
|
expect(filterExpression).toContain(`status_code_string = ${expected}`);
|
||||||
|
expect(filterExpression).not.toContain(`status.code = ${input}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle quoted status code values (browser scenario)', () => {
|
||||||
|
const statusCodeTests = [
|
||||||
|
{ input: '"STATUS_CODE_UNSET"', expected: '"Unset"' },
|
||||||
|
{ input: '"STATUS_CODE_OK"', expected: '"Ok"' },
|
||||||
|
{ input: '"STATUS_CODE_ERROR"', expected: '"Error"' },
|
||||||
|
];
|
||||||
|
|
||||||
|
statusCodeTests.forEach(({ input, expected }) => {
|
||||||
|
const testQuery: Query = {
|
||||||
|
...mockMetricsQuery,
|
||||||
|
builder: {
|
||||||
|
...mockMetricsQuery.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...mockMetricsQuery.builder.queryData[0],
|
||||||
|
filter: {
|
||||||
|
expression: `status.code = ${input}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getViewQuery(
|
||||||
|
testQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
|
||||||
|
// Should preserve the quoting from the original expression
|
||||||
|
expect(filterExpression).toContain(`status_code_string = ${expected}`);
|
||||||
|
expect(filterExpression).not.toContain(`status.code = ${input}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve non-metric attributes during transformation', () => {
|
||||||
|
const mixedQuery: Query = {
|
||||||
|
...mockMetricsQuery,
|
||||||
|
builder: {
|
||||||
|
...mockMetricsQuery.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...mockMetricsQuery.builder.queryData[0],
|
||||||
|
filter: {
|
||||||
|
expression:
|
||||||
|
'operation = "GET" AND service = "test-service" AND span.kind = SPAN_KIND_SERVER AND environment = "prod"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getViewQuery(
|
||||||
|
mixedQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
|
||||||
|
if (filterExpression) {
|
||||||
|
// Transformed attributes
|
||||||
|
expect(filterExpression).toContain('name = "GET"');
|
||||||
|
expect(filterExpression).toContain('kind = 2');
|
||||||
|
|
||||||
|
// Preserved non-metric attributes
|
||||||
|
expect(filterExpression).toContain('service = "test-service"');
|
||||||
|
expect(filterExpression).toContain('environment = "prod"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all span.kind value mappings correctly', () => {
|
||||||
|
const spanKindTests = [
|
||||||
|
{ input: 'SPAN_KIND_INTERNAL', expected: '1' },
|
||||||
|
{ input: 'SPAN_KIND_CONSUMER', expected: '5' },
|
||||||
|
{ input: 'SPAN_KIND_CLIENT', expected: '3' },
|
||||||
|
{ input: 'SPAN_KIND_PRODUCER', expected: '4' },
|
||||||
|
{ input: 'SPAN_KIND_SERVER', expected: '2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
spanKindTests.forEach(({ input, expected }) => {
|
||||||
|
const testQuery: Query = {
|
||||||
|
...mockMetricsQuery,
|
||||||
|
builder: {
|
||||||
|
...mockMetricsQuery.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...mockMetricsQuery.builder.queryData[0],
|
||||||
|
filter: {
|
||||||
|
expression: `span.kind = ${input}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getViewQuery(
|
||||||
|
testQuery,
|
||||||
|
mockFilters,
|
||||||
|
'view_logs',
|
||||||
|
'metrics_query',
|
||||||
|
);
|
||||||
|
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||||
|
|
||||||
|
expect(filterExpression).toContain(`kind = ${expected}`);
|
||||||
|
expect(filterExpression).not.toContain(`span.kind = ${input}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,356 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
export const MOCK_COORDINATES = {
|
||||||
|
x: 996,
|
||||||
|
y: 421,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_AGGREGATE_DATA = {
|
||||||
|
record: {
|
||||||
|
'service.name': 'adservice',
|
||||||
|
trace_id: 'df2cfb0e57bb8736207689851478cd50',
|
||||||
|
A: 3,
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
dataIndex: 'A',
|
||||||
|
title: 'count()',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: true,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
tableColumns: [
|
||||||
|
{
|
||||||
|
dataIndex: 'service.name',
|
||||||
|
title: 'service.name',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: false,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'trace_id',
|
||||||
|
title: 'trace_id',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: false,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'A',
|
||||||
|
title: 'count()',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: true,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_QUERY_WITH_FILTER =
|
||||||
|
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env' service.name = 'adservice' AND trace_id = 'df2cfb0e57bb8736207689851478cd50'";
|
||||||
|
|
||||||
|
export const MOCK_FILTER_DATA = {
|
||||||
|
record: {
|
||||||
|
'service.name': 'adservice',
|
||||||
|
trace_id: 'df2cfb0e57bb8736207689851478cd50',
|
||||||
|
A: 3,
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
dataIndex: 'trace_id',
|
||||||
|
title: 'trace_id',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: false,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
tableColumns: [
|
||||||
|
{
|
||||||
|
dataIndex: 'service.name',
|
||||||
|
title: 'service.name',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: false,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'trace_id',
|
||||||
|
title: 'trace_id',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: false,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'A',
|
||||||
|
title: 'count()',
|
||||||
|
width: 145,
|
||||||
|
isValueColumn: true,
|
||||||
|
queryName: 'A',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_QUERY = {
|
||||||
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
aggregations: [
|
||||||
|
{
|
||||||
|
expression: 'count()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataSource: DataSource.LOGS,
|
||||||
|
disabled: false,
|
||||||
|
expression: 'A',
|
||||||
|
filter: {
|
||||||
|
expression:
|
||||||
|
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env'",
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
items: [],
|
||||||
|
op: 'AND',
|
||||||
|
},
|
||||||
|
functions: [],
|
||||||
|
groupBy: [
|
||||||
|
{
|
||||||
|
dataType: 'string',
|
||||||
|
id: 'service.name--string--resource--false',
|
||||||
|
isColumn: false,
|
||||||
|
isJSON: false,
|
||||||
|
key: 'service.name',
|
||||||
|
type: 'resource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataType: 'string',
|
||||||
|
id: 'trace_id--string----true',
|
||||||
|
isColumn: true,
|
||||||
|
isJSON: false,
|
||||||
|
key: 'trace_id',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
having: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
havingExpression: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
legend: '',
|
||||||
|
limit: null,
|
||||||
|
orderBy: [],
|
||||||
|
queryName: 'A',
|
||||||
|
stepInterval: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aggregations: [
|
||||||
|
{
|
||||||
|
expression: 'count()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataSource: 'logs',
|
||||||
|
disabled: true,
|
||||||
|
expression: 'B',
|
||||||
|
filter: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
items: [],
|
||||||
|
op: 'AND',
|
||||||
|
},
|
||||||
|
functions: [],
|
||||||
|
groupBy: [
|
||||||
|
{
|
||||||
|
dataType: 'string',
|
||||||
|
id: 'service.name--string--resource--false',
|
||||||
|
isColumn: false,
|
||||||
|
isJSON: false,
|
||||||
|
key: 'service.name',
|
||||||
|
type: 'resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
having: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
havingExpression: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
legend: '',
|
||||||
|
limit: null,
|
||||||
|
orderBy: [],
|
||||||
|
queryName: 'B',
|
||||||
|
stepInterval: 60,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
clickhouse_sql: [
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
legend: '',
|
||||||
|
name: 'A',
|
||||||
|
query: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: '6092c3fd-6877-4cb8-836a-7f30db4e4bfe',
|
||||||
|
promql: [
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
legend: '',
|
||||||
|
name: 'A',
|
||||||
|
query: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_KEY_SUGGESTIONS_RESPONSE = {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
complete: true,
|
||||||
|
keys: {
|
||||||
|
resource: [
|
||||||
|
{
|
||||||
|
name: 'service.name',
|
||||||
|
label: 'Service Name',
|
||||||
|
type: 'resource',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deployment.environment',
|
||||||
|
label: 'Environment',
|
||||||
|
type: 'resource',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attribute: [
|
||||||
|
{
|
||||||
|
name: 'http.method',
|
||||||
|
label: 'HTTP Method',
|
||||||
|
type: 'attribute',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'attribute',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'http.status_code',
|
||||||
|
label: 'HTTP Status Code',
|
||||||
|
type: 'attribute',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'attribute',
|
||||||
|
fieldDataType: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_KEY_SUGGESTIONS_SEARCH_RESPONSE = {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
complete: true,
|
||||||
|
keys: {
|
||||||
|
resource: [
|
||||||
|
{
|
||||||
|
name: 'service.name',
|
||||||
|
label: 'Service Name',
|
||||||
|
type: 'resource',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deployment.environment',
|
||||||
|
label: 'Environment',
|
||||||
|
type: 'resource',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'attribute',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_KEY_SUGGESTIONS_SINGLE_RESPONSE = {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
complete: true,
|
||||||
|
keys: {
|
||||||
|
resource: [
|
||||||
|
{
|
||||||
|
name: 'deployment.environment',
|
||||||
|
label: 'Environment',
|
||||||
|
type: 'resource',
|
||||||
|
signal: 'logs',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_QUERY_RANGE_REQUEST = {
|
||||||
|
schemaVersion: 'v1',
|
||||||
|
start: 1756972732000,
|
||||||
|
end: 1756974532000,
|
||||||
|
requestType: 'scalar',
|
||||||
|
compositeQuery: {
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
type: 'builder_query',
|
||||||
|
spec: {
|
||||||
|
name: 'A',
|
||||||
|
signal: 'logs',
|
||||||
|
stepInterval: 60,
|
||||||
|
disabled: false,
|
||||||
|
filter: {
|
||||||
|
expression:
|
||||||
|
'service.name EXISTS AND trace_id EXISTS AND k8s.pod.name EXISTS service.name in $service.name',
|
||||||
|
},
|
||||||
|
groupBy: [
|
||||||
|
{
|
||||||
|
name: 'service.name',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'trace_id',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
fieldContext: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'k8s.pod.name',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
having: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
aggregations: [
|
||||||
|
{
|
||||||
|
expression: 'count()',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
formatOptions: {
|
||||||
|
formatTableResultForUI: true,
|
||||||
|
fillGaps: false,
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
SIGNOZ_START_TIME: {
|
||||||
|
value: 1756972732000,
|
||||||
|
},
|
||||||
|
SIGNOZ_END_TIME: {
|
||||||
|
value: 1756974532000,
|
||||||
|
},
|
||||||
|
'service.name': {
|
||||||
|
value: '__all__',
|
||||||
|
type: 'dynamic',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
PANEL_TYPES,
|
||||||
|
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { getBaseMeta } from './drilldownUtils';
|
||||||
|
import { SUPPORTED_OPERATORS } from './menuOptions';
|
||||||
|
import { BreakoutAttributeType } from './types';
|
||||||
|
|
||||||
|
export type ContextMenuItem = ReactNode;
|
||||||
|
|
||||||
|
export enum ConfigType {
|
||||||
|
GROUP = 'group',
|
||||||
|
AGGREGATE = 'aggregate',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuConfigParams {
|
||||||
|
configType: ConfigType;
|
||||||
|
query: Query;
|
||||||
|
clickedData: ClickedData;
|
||||||
|
panelType?: string;
|
||||||
|
onColumnClick: (key: string, query?: Query) => void;
|
||||||
|
subMenu?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupContextMenuConfig {
|
||||||
|
header?: string;
|
||||||
|
items?: ContextMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateContextMenuConfig {
|
||||||
|
header?: string | ReactNode;
|
||||||
|
items?: ContextMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakoutOptionsProps {
|
||||||
|
queryData: IBuilderQuery;
|
||||||
|
onColumnClick: (groupBy: BreakoutAttributeType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupContextMenuConfig({
|
||||||
|
query,
|
||||||
|
clickedData,
|
||||||
|
panelType,
|
||||||
|
onColumnClick,
|
||||||
|
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
|
||||||
|
const filterKey = clickedData?.column?.dataIndex;
|
||||||
|
|
||||||
|
const filterDataType =
|
||||||
|
getBaseMeta(query, filterKey as string)?.dataType || 'string';
|
||||||
|
|
||||||
|
const operators =
|
||||||
|
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||||
|
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterOperators = operators.filter(
|
||||||
|
(operator) => SUPPORTED_OPERATORS[operator],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (panelType === PANEL_TYPES.TABLE && clickedData?.column) {
|
||||||
|
return {
|
||||||
|
items: (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Header>
|
||||||
|
<div>Filter by {filterKey}</div>
|
||||||
|
</ContextMenu.Header>
|
||||||
|
{filterOperators.map((operator) => (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={operator}
|
||||||
|
icon={SUPPORTED_OPERATORS[operator].icon}
|
||||||
|
onClick={(): void => onColumnClick(SUPPORTED_OPERATORS[operator].value)}
|
||||||
|
>
|
||||||
|
{SUPPORTED_OPERATORS[operator].label}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
export const getDataLinks = (
|
||||||
|
query: Query,
|
||||||
|
aggregateData: AggregateData | null,
|
||||||
|
): {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}[] => {
|
||||||
|
const dataLinks: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// View Trace Details
|
||||||
|
const traceId = aggregateData?.filters.find(
|
||||||
|
(filter) => filter.filterKey === 'trace_id',
|
||||||
|
)?.filterValue;
|
||||||
|
if (traceId) {
|
||||||
|
dataLinks.push({
|
||||||
|
id: uuid(),
|
||||||
|
label: 'View Trace Details',
|
||||||
|
url: `/trace/${traceId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataLinks;
|
||||||
|
};
|
||||||
473
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
473
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
import { PieArcDatum } from '@visx/shape/lib/shapes/Pie';
|
||||||
|
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||||
|
import {
|
||||||
|
initialQueryBuilderFormValuesMap,
|
||||||
|
OPERATORS,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import cloneDeep from 'lodash-es/cloneDeep';
|
||||||
|
import {
|
||||||
|
BaseAutocompleteData,
|
||||||
|
DataTypes,
|
||||||
|
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import {
|
||||||
|
IBuilderQuery,
|
||||||
|
Query,
|
||||||
|
TagFilterItem,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
export function getBaseMeta(
|
||||||
|
query: Query,
|
||||||
|
filterKey: string,
|
||||||
|
): BaseAutocompleteData | null {
|
||||||
|
const steps = query.builder.queryData;
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const { groupBy = [] } = steps[i];
|
||||||
|
for (let j = 0; j < groupBy.length; j++) {
|
||||||
|
if (groupBy[j].key === filterKey) {
|
||||||
|
return groupBy[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRoute = (key: string): string => {
|
||||||
|
switch (key) {
|
||||||
|
case 'view_logs':
|
||||||
|
return ROUTES.LOGS_EXPLORER;
|
||||||
|
case 'view_metrics':
|
||||||
|
return ROUTES.METRICS_EXPLORER;
|
||||||
|
case 'view_traces':
|
||||||
|
return ROUTES.TRACES_EXPLORER;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
|
||||||
|
if (!dataType) return false;
|
||||||
|
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FilterData {
|
||||||
|
filterKey: string;
|
||||||
|
filterValue: string | number;
|
||||||
|
operator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to avoid code duplication
|
||||||
|
function addFiltersToQuerySteps(
|
||||||
|
query: Query,
|
||||||
|
filters: FilterData[],
|
||||||
|
queryName?: string,
|
||||||
|
): Query {
|
||||||
|
// 1) clone so we don't mutate the original
|
||||||
|
const q = cloneDeep(query);
|
||||||
|
|
||||||
|
// 2) map over builder.queryData to return a new modified version
|
||||||
|
q.builder.queryData = q.builder.queryData.map((step) => {
|
||||||
|
// Only modify the step that matches the queryName (if provided)
|
||||||
|
if (queryName && step.queryName !== queryName) {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) build the new filters array
|
||||||
|
const newFilters = {
|
||||||
|
...step.filters,
|
||||||
|
op: step?.filters?.op || 'AND',
|
||||||
|
items: [...(step?.filters?.items || [])],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add each filter to the items array
|
||||||
|
filters.forEach(({ filterKey, filterValue, operator }) => {
|
||||||
|
// skip if this step doesn't group by our key
|
||||||
|
const baseMeta = step.groupBy.find((g) => g.key === filterKey);
|
||||||
|
if (!baseMeta) return;
|
||||||
|
|
||||||
|
newFilters.items.push({
|
||||||
|
id: uuid(),
|
||||||
|
key: baseMeta,
|
||||||
|
op: operator,
|
||||||
|
value: filterValue,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedFilters = convertFiltersToExpressionWithExistingQuery(
|
||||||
|
newFilters,
|
||||||
|
step.filter?.expression,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4) return a new step object with updated filters
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
...resolvedFilters,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFilterToQuery(query: Query, filters: FilterData[]): Query {
|
||||||
|
return addFiltersToQuerySteps(query, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addFilterToSelectedQuery = (
|
||||||
|
query: Query,
|
||||||
|
filters: FilterData[],
|
||||||
|
queryName: string,
|
||||||
|
): Query => addFiltersToQuerySteps(query, filters, queryName);
|
||||||
|
|
||||||
|
export const getAggregateColumnHeader = (
|
||||||
|
query: Query,
|
||||||
|
queryName: string,
|
||||||
|
): { dataSource: string; aggregations: string } => {
|
||||||
|
// Find the query step with the matching queryName
|
||||||
|
const queryStep = query?.builder?.queryData.find(
|
||||||
|
(step) => step.queryName === queryName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!queryStep) {
|
||||||
|
return { dataSource: '', aggregations: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataSource, aggregations } = queryStep; // TODO: check if this is correct
|
||||||
|
|
||||||
|
// Extract aggregation expressions based on data source type
|
||||||
|
let aggregationExpressions: string[] = [];
|
||||||
|
|
||||||
|
if (aggregations && aggregations.length > 0) {
|
||||||
|
if (dataSource === 'metrics') {
|
||||||
|
// For metrics, construct expression from spaceAggregation(metricName)
|
||||||
|
aggregationExpressions = aggregations.map((agg: any) => {
|
||||||
|
const { spaceAggregation, metricName } = agg;
|
||||||
|
return `${spaceAggregation}(${metricName})`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For traces and logs, use the expression field directly
|
||||||
|
aggregationExpressions = aggregations.map((agg: any) => agg.expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
aggregations: aggregationExpressions.join(', '),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||||
|
Object.keys(metric).map((key) => ({
|
||||||
|
filterKey: key,
|
||||||
|
filterValue: metric[key],
|
||||||
|
operator: OPERATORS['='],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const getUplotClickData = ({
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
focusedSeries,
|
||||||
|
}: {
|
||||||
|
metric?: { [key: string]: string };
|
||||||
|
queryData?: { queryName: string; inFocusOrNot: boolean };
|
||||||
|
absoluteMouseX: number;
|
||||||
|
absoluteMouseY: number;
|
||||||
|
focusedSeries?: {
|
||||||
|
seriesIndex: number;
|
||||||
|
seriesName: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
show: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
|
} | null;
|
||||||
|
}): {
|
||||||
|
coord: { x: number; y: number };
|
||||||
|
record: { queryName: string; filters: FilterData[] };
|
||||||
|
label: string | React.ReactNode;
|
||||||
|
} | null => {
|
||||||
|
if (!queryData?.queryName || !metric) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
queryName: queryData.queryName,
|
||||||
|
filters: getFiltersFromMetric(metric),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate label from focusedSeries data
|
||||||
|
let label: string | React.ReactNode = '';
|
||||||
|
if (focusedSeries && focusedSeries.seriesName) {
|
||||||
|
label = (
|
||||||
|
<span style={{ color: focusedSeries.color }}>
|
||||||
|
{focusedSeries.seriesName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord: {
|
||||||
|
x: absoluteMouseX,
|
||||||
|
y: absoluteMouseY,
|
||||||
|
},
|
||||||
|
record,
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPieChartClickData = (
|
||||||
|
arc: PieArcDatum<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
record: any;
|
||||||
|
}>,
|
||||||
|
): {
|
||||||
|
queryName: string;
|
||||||
|
filters: FilterData[];
|
||||||
|
label: string | React.ReactNode;
|
||||||
|
} | null => {
|
||||||
|
const { metric, queryName } = arc.data.record;
|
||||||
|
if (!queryName || !metric) return null;
|
||||||
|
|
||||||
|
const label = <span style={{ color: arc.data.color }}>{arc.data.label}</span>;
|
||||||
|
return {
|
||||||
|
queryName,
|
||||||
|
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the query data that matches the aggregate data's queryName
|
||||||
|
*/
|
||||||
|
export const getQueryData = (
|
||||||
|
query: Query,
|
||||||
|
queryName: string,
|
||||||
|
): IBuilderQuery => {
|
||||||
|
const queryData = query?.builder?.queryData?.filter(
|
||||||
|
(item: IBuilderQuery) => item.queryName === queryName,
|
||||||
|
);
|
||||||
|
return queryData[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a query name is valid for drilldown operations
|
||||||
|
* Returns false if queryName is empty or starts with 'F'
|
||||||
|
* Note: Checking if queryName starts with 'F' is a hack to know if it's a Formulae based query
|
||||||
|
*/
|
||||||
|
export const isValidQueryName = (queryName: string): boolean => {
|
||||||
|
if (!queryName || queryName.trim() === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !queryName.startsWith('F');
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEW_QUERY_MAP: Record<string, IBuilderQuery> = {
|
||||||
|
view_logs: initialQueryBuilderFormValuesMap.logs,
|
||||||
|
view_metrics: initialQueryBuilderFormValuesMap.metrics,
|
||||||
|
view_traces: initialQueryBuilderFormValuesMap.traces,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMP LOGIC - TO BE REMOVED LATER
|
||||||
|
* Transforms metric query filters to logs/traces format
|
||||||
|
* Applies the following transformations:
|
||||||
|
* - Rule 2: operation → name
|
||||||
|
* - Rule 3: span.kind → kind
|
||||||
|
* - Rule 4: status.code → status_code_string with value mapping
|
||||||
|
* - Rule 5: http.status_code type conversion
|
||||||
|
*/
|
||||||
|
const transformMetricsToLogsTraces = (
|
||||||
|
filterExpression: string | undefined,
|
||||||
|
): string | undefined => {
|
||||||
|
if (!filterExpression) return filterExpression;
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// MAPPING OBJECTS - ALL TRANSFORMATIONS DEFINED HERE
|
||||||
|
// ===========================================
|
||||||
|
const METRIC_TO_LOGS_TRACES_MAPPINGS = {
|
||||||
|
// Rule 2: operation → name
|
||||||
|
attributeRenames: {
|
||||||
|
operation: 'name',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rule 3: span.kind → kind with value mapping
|
||||||
|
spanKindMapping: {
|
||||||
|
attribute: 'span.kind',
|
||||||
|
newAttribute: 'kind',
|
||||||
|
valueMappings: {
|
||||||
|
SPAN_KIND_INTERNAL: '1',
|
||||||
|
SPAN_KIND_SERVER: '2',
|
||||||
|
SPAN_KIND_CLIENT: '3',
|
||||||
|
SPAN_KIND_PRODUCER: '4',
|
||||||
|
SPAN_KIND_CONSUMER: '5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rule 4: status.code → status_code_string with value mapping
|
||||||
|
statusCodeMapping: {
|
||||||
|
attribute: 'status.code',
|
||||||
|
newAttribute: 'status_code_string',
|
||||||
|
valueMappings: {
|
||||||
|
// From metrics format → To logs/traces format
|
||||||
|
STATUS_CODE_UNSET: 'Unset',
|
||||||
|
STATUS_CODE_OK: 'Ok',
|
||||||
|
STATUS_CODE_ERROR: 'Error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rule 5: http.status_code type conversion
|
||||||
|
typeConversions: {
|
||||||
|
'http.status_code': 'number',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
let transformedExpression = filterExpression;
|
||||||
|
|
||||||
|
// Apply attribute renames
|
||||||
|
Object.entries(METRIC_TO_LOGS_TRACES_MAPPINGS.attributeRenames).forEach(
|
||||||
|
([oldAttr, newAttr]) => {
|
||||||
|
const regex = new RegExp(`\\b${oldAttr}\\b`, 'g');
|
||||||
|
transformedExpression = transformedExpression.replace(regex, newAttr);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply span.kind → kind transformation
|
||||||
|
const { spanKindMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
|
||||||
|
if (spanKindMapping) {
|
||||||
|
// Replace attribute name - use word boundaries to avoid partial matches
|
||||||
|
const attrRegex = new RegExp(
|
||||||
|
`\\b${spanKindMapping.attribute.replace(/\./g, '\\.')}\\b`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
transformedExpression = transformedExpression.replace(
|
||||||
|
attrRegex,
|
||||||
|
spanKindMapping.newAttribute,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace values
|
||||||
|
Object.entries(spanKindMapping.valueMappings).forEach(
|
||||||
|
([oldValue, newValue]) => {
|
||||||
|
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
|
||||||
|
transformedExpression = transformedExpression.replace(valueRegex, newValue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status.code → status_code_string transformation
|
||||||
|
const { statusCodeMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
|
||||||
|
if (statusCodeMapping) {
|
||||||
|
// Replace attribute name - use word boundaries to avoid partial matches
|
||||||
|
// This prevents http.status_code from being transformed
|
||||||
|
const attrRegex = new RegExp(
|
||||||
|
`\\b${statusCodeMapping.attribute.replace(/\./g, '\\.')}\\b`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
transformedExpression = transformedExpression.replace(
|
||||||
|
attrRegex,
|
||||||
|
statusCodeMapping.newAttribute,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace values
|
||||||
|
Object.entries(statusCodeMapping.valueMappings).forEach(
|
||||||
|
([oldValue, newValue]) => {
|
||||||
|
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
|
||||||
|
transformedExpression = transformedExpression.replace(
|
||||||
|
valueRegex,
|
||||||
|
`${newValue}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Type conversions (Rule 5) would need more complex parsing
|
||||||
|
// of the filter expression to implement properly
|
||||||
|
|
||||||
|
return transformedExpression;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getViewQuery = (
|
||||||
|
query: Query,
|
||||||
|
filtersToAdd: FilterData[],
|
||||||
|
key: string,
|
||||||
|
queryName: string,
|
||||||
|
): Query | null => {
|
||||||
|
const newQuery = cloneDeep(query);
|
||||||
|
|
||||||
|
const queryBuilderData = VIEW_QUERY_MAP[key];
|
||||||
|
|
||||||
|
if (!queryBuilderData) return null;
|
||||||
|
|
||||||
|
let existingFilters: TagFilterItem[] = [];
|
||||||
|
let existingFilterExpression: string | undefined;
|
||||||
|
if (queryName) {
|
||||||
|
const queryData = getQueryData(query, queryName);
|
||||||
|
existingFilters = queryData?.filters?.items || [];
|
||||||
|
existingFilterExpression = queryData?.filter?.expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
newQuery.builder.queryData = [queryBuilderData];
|
||||||
|
|
||||||
|
const filters = filtersToAdd.reduce((acc: any[], filter) => {
|
||||||
|
// use existing query to get baseMeta
|
||||||
|
const baseMeta = getBaseMeta(query, filter.filterKey);
|
||||||
|
if (!baseMeta) return acc;
|
||||||
|
|
||||||
|
acc.push({
|
||||||
|
id: uuid(),
|
||||||
|
key: baseMeta,
|
||||||
|
op: filter.operator,
|
||||||
|
value: filter.filterValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allFilters = [...existingFilters, ...filters];
|
||||||
|
|
||||||
|
const {
|
||||||
|
// filters: newFilters,
|
||||||
|
filter: newFilterExpression,
|
||||||
|
} = convertFiltersToExpressionWithExistingQuery(
|
||||||
|
{
|
||||||
|
items: allFilters,
|
||||||
|
op: 'AND',
|
||||||
|
},
|
||||||
|
existingFilterExpression,
|
||||||
|
);
|
||||||
|
|
||||||
|
// newQuery.builder.queryData[0].filters = newFilters;
|
||||||
|
|
||||||
|
newQuery.builder.queryData[0].filter = newFilterExpression;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ===========================================
|
||||||
|
// TEMP LOGIC - TO BE REMOVED LATER
|
||||||
|
// ===========================================
|
||||||
|
// Apply metric-to-logs/traces transformations
|
||||||
|
if (key === 'view_logs' || key === 'view_traces') {
|
||||||
|
const transformedExpression = transformMetricsToLogsTraces(
|
||||||
|
newFilterExpression?.expression,
|
||||||
|
);
|
||||||
|
newQuery.builder.queryData[0].filter = {
|
||||||
|
expression: transformedExpression || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ===========================================
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error transforming metrics to logs/traces:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isDrilldownEnabled(): boolean {
|
||||||
|
return true;
|
||||||
|
// temp code
|
||||||
|
// if (typeof window === 'undefined') return false;
|
||||||
|
// const drilldownValue = window.localStorage.getItem('drilldown');
|
||||||
|
// return drilldownValue === 'true';
|
||||||
|
}
|
||||||
114
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
114
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { OPERATORS } from 'constants/queryBuilder';
|
||||||
|
import { Braces, ChartBar, DraftingCompass, ScrollText } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported operators for filtering with their display properties
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_OPERATORS = {
|
||||||
|
[OPERATORS['=']]: {
|
||||||
|
label: 'Is this',
|
||||||
|
icon: '=',
|
||||||
|
value: '=',
|
||||||
|
},
|
||||||
|
[OPERATORS['!=']]: {
|
||||||
|
label: 'Is not this',
|
||||||
|
icon: '!=',
|
||||||
|
value: '!=',
|
||||||
|
},
|
||||||
|
[OPERATORS['>=']]: {
|
||||||
|
label: 'Is greater than or equal to',
|
||||||
|
icon: '>=',
|
||||||
|
value: '>=',
|
||||||
|
},
|
||||||
|
[OPERATORS['<=']]: {
|
||||||
|
label: 'Is less than or equal to',
|
||||||
|
icon: '<=',
|
||||||
|
value: '<=',
|
||||||
|
},
|
||||||
|
[OPERATORS['<']]: {
|
||||||
|
label: 'Is less than',
|
||||||
|
icon: '<',
|
||||||
|
value: '<',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate menu options for different views
|
||||||
|
*/
|
||||||
|
// TO REMOVE
|
||||||
|
export const AGGREGATE_OPTIONS = [
|
||||||
|
{
|
||||||
|
key: 'view_logs',
|
||||||
|
icon: <ScrollText size={16} />,
|
||||||
|
label: 'View in Logs',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// key: 'view_metrics',
|
||||||
|
// icon: <BarChart2 size={16} />,
|
||||||
|
// label: 'View in Metrics',
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
key: 'view_traces',
|
||||||
|
icon: <DraftingCompass size={16} />,
|
||||||
|
label: 'View in Traces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'breakout',
|
||||||
|
icon: <ChartBar size={16} />,
|
||||||
|
label: 'Breakout by ..',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate menu options for different views
|
||||||
|
*/
|
||||||
|
export const getBaseContextConfig = ({
|
||||||
|
handleBaseDrilldown,
|
||||||
|
setSubMenu,
|
||||||
|
showDashboardVariablesOption,
|
||||||
|
showBreakoutOption,
|
||||||
|
}: {
|
||||||
|
handleBaseDrilldown: (key: string) => void;
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
showDashboardVariablesOption: boolean;
|
||||||
|
showBreakoutOption: boolean;
|
||||||
|
}): {
|
||||||
|
key: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
hidden?: boolean;
|
||||||
|
}[] => [
|
||||||
|
{
|
||||||
|
key: 'dashboard_variables',
|
||||||
|
icon: <Braces size={16} />,
|
||||||
|
label: 'Dashboard Variables',
|
||||||
|
onClick: (): void => setSubMenu('dashboard_variables'),
|
||||||
|
hidden: !showDashboardVariablesOption,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'view_logs',
|
||||||
|
icon: <ScrollText size={16} />,
|
||||||
|
label: 'View in Logs',
|
||||||
|
onClick: (): void => handleBaseDrilldown('view_logs'),
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// key: 'view_metrics',
|
||||||
|
// icon: <BarChart2 size={16} />,
|
||||||
|
// label: 'View in Metrics',
|
||||||
|
// onClick: () => handleBaseDrilldown('view_metrics'),
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
key: 'view_traces',
|
||||||
|
icon: <DraftingCompass size={16} />,
|
||||||
|
label: 'View in Traces',
|
||||||
|
onClick: (): void => handleBaseDrilldown('view_traces'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'breakout',
|
||||||
|
icon: <ChartBar size={16} />,
|
||||||
|
label: 'Breakout by ..',
|
||||||
|
onClick: (): void => setSubMenu('breakout'),
|
||||||
|
hidden: !showBreakoutOption,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import cloneDeep from 'lodash-es/cloneDeep';
|
||||||
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { addFilterToSelectedQuery, FilterData } from './drilldownUtils';
|
||||||
|
import { BreakoutAttributeType } from './types';
|
||||||
|
import { AggregateData } from './useAggregateDrilldown';
|
||||||
|
|
||||||
|
export const isEmptyFilterValue = (value: any): boolean =>
|
||||||
|
value === '' || value === null || value === undefined || value === 'n/a';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates filters to add to the query from table columns for view mode navigation
|
||||||
|
*/
|
||||||
|
export const getFiltersToAddToView = (clickedData: any): FilterData[] => {
|
||||||
|
if (!clickedData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
clickedData?.tableColumns
|
||||||
|
?.filter((col: any) => !col.isValueColumn)
|
||||||
|
.reduce((acc: FilterData[], col: any) => {
|
||||||
|
// only add table col which have isValueColumn false. and the filter value suffices the isEmptyFilterValue condition.
|
||||||
|
const { dataIndex } = col;
|
||||||
|
if (!dataIndex || typeof dataIndex !== 'string') return acc;
|
||||||
|
if (
|
||||||
|
clickedData?.column?.isValueColumn &&
|
||||||
|
isEmptyFilterValue(clickedData?.record?.[dataIndex])
|
||||||
|
)
|
||||||
|
return acc;
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
filterKey: dataIndex,
|
||||||
|
filterValue: clickedData?.record?.[dataIndex] || '',
|
||||||
|
operator: OPERATORS['='],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, []) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBreakoutPanelType = (
|
||||||
|
// breakoutQuery: Query,
|
||||||
|
currentPanelType?: PANEL_TYPES,
|
||||||
|
// groupBy?: BreakoutAttributeType,
|
||||||
|
): PANEL_TYPES => {
|
||||||
|
// // Check if the query is grouped by a number data type
|
||||||
|
// const hasNumberGroupBy = groupBy?.dataType === 'number';
|
||||||
|
|
||||||
|
// if (hasNumberGroupBy) {
|
||||||
|
// return PANEL_TYPES.HISTOGRAM;
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (currentPanelType === PANEL_TYPES.VALUE) {
|
||||||
|
return PANEL_TYPES.TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPanelType || PANEL_TYPES.TIME_SERIES;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a breakout query by adding filters and updating the groupBy
|
||||||
|
*/
|
||||||
|
export const getBreakoutQuery = (
|
||||||
|
query: Query,
|
||||||
|
aggregateData: AggregateData | null,
|
||||||
|
groupBy: BreakoutAttributeType,
|
||||||
|
filtersToAdd: FilterData[],
|
||||||
|
): Query => {
|
||||||
|
if (!aggregateData) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryWithFilters = addFilterToSelectedQuery(
|
||||||
|
query,
|
||||||
|
filtersToAdd,
|
||||||
|
aggregateData.queryName,
|
||||||
|
);
|
||||||
|
const newQuery = cloneDeep(queryWithFilters);
|
||||||
|
|
||||||
|
// Filter to keep only the query that matches queryName
|
||||||
|
newQuery.builder.queryData = newQuery.builder.queryData
|
||||||
|
.filter((item: IBuilderQuery) => item.queryName === aggregateData.queryName)
|
||||||
|
.map((item: IBuilderQuery) => ({
|
||||||
|
...item,
|
||||||
|
groupBy: [{ key: groupBy.key, type: groupBy.type } as BaseAutocompleteData],
|
||||||
|
orderBy: [],
|
||||||
|
legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return newQuery;
|
||||||
|
};
|
||||||
41
frontend/src/container/QueryTable/Drilldown/types.ts
Normal file
41
frontend/src/container/QueryTable/Drilldown/types.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
export type ContextMenuItem = ReactNode;
|
||||||
|
|
||||||
|
export enum ConfigType {
|
||||||
|
GROUP = 'group',
|
||||||
|
AGGREGATE = 'aggregate',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuConfigParams {
|
||||||
|
configType: ConfigType;
|
||||||
|
query: any; // Query type
|
||||||
|
clickedData: any;
|
||||||
|
panelType?: string;
|
||||||
|
onColumnClick: (operator: string | any) => void; // Query type
|
||||||
|
subMenu?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupContextMenuConfig {
|
||||||
|
header?: string;
|
||||||
|
items?: ContextMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateContextMenuConfig {
|
||||||
|
header?: string;
|
||||||
|
items?: ContextMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakoutAttributeType {
|
||||||
|
key: string;
|
||||||
|
dataType: QUERY_BUILDER_KEY_TYPES;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakoutOptionsProps {
|
||||||
|
queryData: IBuilderQuery;
|
||||||
|
onColumnClick: (groupBy: BaseAutocompleteData) => void;
|
||||||
|
}
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import useDashboardVarConfig from 'container/QueryTable/Drilldown/useDashboardVarConfig';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { UseQueryResult } from 'react-query';
|
||||||
|
import { SuccessResponse } from 'types/api';
|
||||||
|
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { getTimeRange } from 'utils/getTimeRange';
|
||||||
|
|
||||||
|
import { ContextMenuItem } from './contextConfig';
|
||||||
|
import { FilterData, getQueryData } from './drilldownUtils';
|
||||||
|
import useBaseAggregateOptions from './useBaseAggregateOptions';
|
||||||
|
import useBreakout from './useBreakout';
|
||||||
|
|
||||||
|
// Type for aggregate data
|
||||||
|
export interface AggregateData {
|
||||||
|
queryName: string;
|
||||||
|
filters: FilterData[];
|
||||||
|
timeRange?: {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
label?: string | React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAggregateDrilldown = ({
|
||||||
|
query,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
aggregateData,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
queryRange,
|
||||||
|
}: {
|
||||||
|
query: Query;
|
||||||
|
widgetId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
subMenu: string;
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
aggregateData: AggregateData | null;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
queryRange?: UseQueryResult<
|
||||||
|
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||||
|
Error
|
||||||
|
>;
|
||||||
|
}): {
|
||||||
|
aggregateDrilldownConfig: {
|
||||||
|
header?: string | React.ReactNode;
|
||||||
|
items?: ContextMenuItem;
|
||||||
|
};
|
||||||
|
} => {
|
||||||
|
// Ensure aggregateData has timeRange, fallback to widget time or global time if not provided
|
||||||
|
const aggregateDataWithTimeRange = useMemo(() => {
|
||||||
|
if (!aggregateData) return null;
|
||||||
|
|
||||||
|
// If timeRange is already provided, use it
|
||||||
|
if (aggregateData.timeRange) return aggregateData;
|
||||||
|
|
||||||
|
// Try to get widget-specific time range first, then fall back to global time
|
||||||
|
const timeRangeData = getTimeRange(queryRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...aggregateData,
|
||||||
|
timeRange: {
|
||||||
|
startTime: timeRangeData.startTime,
|
||||||
|
endTime: timeRangeData.endTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [aggregateData]);
|
||||||
|
|
||||||
|
const { breakoutConfig } = useBreakout({
|
||||||
|
query,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
aggregateData: aggregateDataWithTimeRange,
|
||||||
|
setSubMenu,
|
||||||
|
panelType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldVariables = useMemo(() => {
|
||||||
|
if (!aggregateDataWithTimeRange?.filters) return {};
|
||||||
|
|
||||||
|
// Extract field variables from aggregation data filters
|
||||||
|
const fieldVars: Record<string, string | number | boolean> = {};
|
||||||
|
|
||||||
|
// Get groupBy fields from the specific queryData item that matches the queryName
|
||||||
|
const groupByFields: string[] = [];
|
||||||
|
if (aggregateDataWithTimeRange.queryName) {
|
||||||
|
// Find the specific queryData item that matches the queryName
|
||||||
|
const matchingQueryData = getQueryData(
|
||||||
|
query,
|
||||||
|
aggregateDataWithTimeRange.queryName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingQueryData?.groupBy) {
|
||||||
|
matchingQueryData.groupBy.forEach((field) => {
|
||||||
|
if (field.key && !groupByFields.includes(field.key)) {
|
||||||
|
groupByFields.push(field.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregateDataWithTimeRange.filters.forEach((filter) => {
|
||||||
|
if (filter.filterKey && filter.filterValue !== undefined) {
|
||||||
|
// Check if this field is present in groupBy from the query
|
||||||
|
const isFieldInGroupBy = groupByFields.includes(filter.filterKey);
|
||||||
|
|
||||||
|
if (isFieldInGroupBy) {
|
||||||
|
fieldVars[filter.filterKey] = filter.filterValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fieldVars;
|
||||||
|
}, [
|
||||||
|
aggregateDataWithTimeRange?.filters,
|
||||||
|
aggregateDataWithTimeRange?.queryName,
|
||||||
|
query,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { dashbaordVariablesConfig } = useDashboardVarConfig({
|
||||||
|
setSubMenu,
|
||||||
|
fieldVariables,
|
||||||
|
query,
|
||||||
|
// panelType,
|
||||||
|
aggregateData: aggregateDataWithTimeRange,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { baseAggregateOptionsConfig } = useBaseAggregateOptions({
|
||||||
|
query,
|
||||||
|
onClose,
|
||||||
|
aggregateData: aggregateDataWithTimeRange,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
fieldVariables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggregateDrilldownConfig = useMemo(() => {
|
||||||
|
if (!aggregateDataWithTimeRange) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subMenu === 'breakout') {
|
||||||
|
// todo: declare keys in constants
|
||||||
|
return breakoutConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subMenu === 'dashboard_variables') {
|
||||||
|
return dashbaordVariablesConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseAggregateOptionsConfig;
|
||||||
|
}, [
|
||||||
|
subMenu,
|
||||||
|
aggregateDataWithTimeRange,
|
||||||
|
breakoutConfig,
|
||||||
|
baseAggregateOptionsConfig,
|
||||||
|
dashbaordVariablesConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { aggregateDrilldownConfig };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAggregateDrilldown;
|
||||||
@ -0,0 +1,260 @@
|
|||||||
|
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||||
|
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||||
|
import useContextVariables from 'hooks/dashboard/useContextVariables';
|
||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
|
import createQueryParams from 'lib/createQueryParams';
|
||||||
|
import ContextMenu from 'periscope/components/ContextMenu';
|
||||||
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { ContextMenuItem } from './contextConfig';
|
||||||
|
import { getDataLinks } from './dataLinksUtils';
|
||||||
|
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
|
||||||
|
import { getBaseContextConfig } from './menuOptions';
|
||||||
|
import { AggregateData } from './useAggregateDrilldown';
|
||||||
|
|
||||||
|
interface UseBaseAggregateOptionsProps {
|
||||||
|
query: Query;
|
||||||
|
onClose: () => void;
|
||||||
|
subMenu: string;
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
aggregateData: AggregateData | null;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
fieldVariables: Record<string, string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseAggregateOptionsConfig {
|
||||||
|
header?: string | React.ReactNode;
|
||||||
|
items?: ContextMenuItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoute = (key: string): string => {
|
||||||
|
switch (key) {
|
||||||
|
case 'view_logs':
|
||||||
|
return ROUTES.LOGS_EXPLORER;
|
||||||
|
case 'view_metrics':
|
||||||
|
return ROUTES.METRICS_EXPLORER;
|
||||||
|
case 'view_traces':
|
||||||
|
return ROUTES.TRACES_EXPLORER;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useBaseAggregateOptions = ({
|
||||||
|
query,
|
||||||
|
onClose,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
aggregateData,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
fieldVariables,
|
||||||
|
}: UseBaseAggregateOptionsProps): {
|
||||||
|
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
|
||||||
|
} => {
|
||||||
|
const [resolvedQuery, setResolvedQuery] = useState<Query>(query);
|
||||||
|
const {
|
||||||
|
getUpdatedQuery,
|
||||||
|
isLoading: isResolveQueryLoading,
|
||||||
|
} = useUpdatedQuery();
|
||||||
|
const { selectedDashboard } = useDashboard();
|
||||||
|
|
||||||
|
console.log('>>V subMenu', subMenu);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aggregateData) return;
|
||||||
|
const resolveQuery = async (): Promise<void> => {
|
||||||
|
const updatedQuery = await getUpdatedQuery({
|
||||||
|
widgetConfig: {
|
||||||
|
query,
|
||||||
|
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
|
||||||
|
timePreferance: 'GLOBAL_TIME',
|
||||||
|
},
|
||||||
|
selectedDashboard,
|
||||||
|
});
|
||||||
|
setResolvedQuery(updatedQuery);
|
||||||
|
};
|
||||||
|
resolveQuery();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query, aggregateData, panelType]);
|
||||||
|
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
|
// Use the new useContextVariables hook
|
||||||
|
const { processedVariables } = useContextVariables({
|
||||||
|
maxValues: 2,
|
||||||
|
customVariables: fieldVariables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getContextLinksItems = useCallback(() => {
|
||||||
|
if (!contextLinks?.linksData) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processedLinks = processContextLinks(
|
||||||
|
contextLinks.linksData,
|
||||||
|
processedVariables,
|
||||||
|
50, // maxLength for labels
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataLinks = getDataLinks(query, aggregateData);
|
||||||
|
const allLinks = [...dataLinks, ...processedLinks];
|
||||||
|
|
||||||
|
return allLinks.map(({ id, label, url }) => (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={id}
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
onClick={(): void => {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
|
||||||
|
|
||||||
|
const handleBaseDrilldown = useCallback(
|
||||||
|
(key: string): void => {
|
||||||
|
console.log('Base drilldown:', { key, aggregateData });
|
||||||
|
const route = getRoute(key);
|
||||||
|
const timeRange = aggregateData?.timeRange;
|
||||||
|
const filtersToAdd = aggregateData?.filters || [];
|
||||||
|
const viewQuery = getViewQuery(
|
||||||
|
resolvedQuery,
|
||||||
|
filtersToAdd,
|
||||||
|
key,
|
||||||
|
aggregateData?.queryName || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
let queryParams = {
|
||||||
|
[QueryParams.compositeQuery]: JSON.stringify(viewQuery),
|
||||||
|
...(timeRange && {
|
||||||
|
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||||
|
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||||
|
}),
|
||||||
|
} as Record<string, string>;
|
||||||
|
|
||||||
|
if (route === ROUTES.METRICS_EXPLORER) {
|
||||||
|
queryParams = {
|
||||||
|
...queryParams,
|
||||||
|
[QueryParams.summaryFilters]: JSON.stringify(
|
||||||
|
viewQuery?.builder.queryData[0].filters,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route) {
|
||||||
|
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||||
|
newTab: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[resolvedQuery, safeNavigate, onClose, aggregateData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const showDashboardVariablesOption = useMemo(() => {
|
||||||
|
const fieldVariablesExist = Object.keys(fieldVariables).length > 0;
|
||||||
|
// Check if current route is exactly dashboard route (/dashboard/:dashboardId)
|
||||||
|
const dashboardPattern = /^\/dashboard\/[^/]+$/;
|
||||||
|
return fieldVariablesExist && dashboardPattern.test(pathname);
|
||||||
|
}, [pathname, fieldVariables]);
|
||||||
|
|
||||||
|
const baseAggregateOptionsConfig = useMemo(() => {
|
||||||
|
if (!aggregateData) {
|
||||||
|
console.warn('aggregateData is null in baseAggregateOptionsConfig');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the non-breakout logic from getAggregateContextMenuConfig
|
||||||
|
const { queryName } = aggregateData;
|
||||||
|
const { dataSource, aggregations } = getAggregateColumnHeader(
|
||||||
|
resolvedQuery,
|
||||||
|
queryName as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseContextConfig = getBaseContextConfig({
|
||||||
|
handleBaseDrilldown,
|
||||||
|
setSubMenu,
|
||||||
|
showDashboardVariablesOption,
|
||||||
|
showBreakoutOption: true,
|
||||||
|
}).filter((item) => !item.hidden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Header>
|
||||||
|
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 'normal',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{aggregateData?.label || aggregations}
|
||||||
|
</div>
|
||||||
|
</ContextMenu.Header>
|
||||||
|
<div>
|
||||||
|
<OverlayScrollbar
|
||||||
|
style={{ maxHeight: '200px' }}
|
||||||
|
options={{
|
||||||
|
overflow: {
|
||||||
|
x: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{baseContextConfig.map(({ key, label, icon, onClick }) => {
|
||||||
|
const isLoading =
|
||||||
|
isResolveQueryLoading &&
|
||||||
|
(key === 'view_logs' || key === 'view_traces');
|
||||||
|
return (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={key}
|
||||||
|
icon={isLoading ? <LoadingOutlined spin /> : icon}
|
||||||
|
onClick={(): void => onClick()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</ContextMenu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{getContextLinksItems()}
|
||||||
|
</>
|
||||||
|
</OverlayScrollbar>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
handleBaseDrilldown,
|
||||||
|
aggregateData,
|
||||||
|
getContextLinksItems,
|
||||||
|
isResolveQueryLoading,
|
||||||
|
resolvedQuery,
|
||||||
|
showDashboardVariablesOption,
|
||||||
|
setSubMenu,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { baseAggregateOptionsConfig };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBaseAggregateOptions;
|
||||||
120
frontend/src/container/QueryTable/Drilldown/useBreakout.tsx
Normal file
120
frontend/src/container/QueryTable/Drilldown/useBreakout.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import ContextMenu from 'periscope/components/ContextMenu';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import BreakoutOptions from './BreakoutOptions';
|
||||||
|
import { getQueryData } from './drilldownUtils';
|
||||||
|
import { getBreakoutPanelType, getBreakoutQuery } from './tableDrilldownUtils';
|
||||||
|
import { BreakoutAttributeType } from './types';
|
||||||
|
import { AggregateData } from './useAggregateDrilldown';
|
||||||
|
|
||||||
|
interface UseBreakoutProps {
|
||||||
|
query: Query;
|
||||||
|
widgetId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
aggregateData: AggregateData | null;
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreakoutConfig {
|
||||||
|
header?: string | React.ReactNode;
|
||||||
|
items?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useBreakout = ({
|
||||||
|
query,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
aggregateData,
|
||||||
|
setSubMenu,
|
||||||
|
panelType,
|
||||||
|
}: UseBreakoutProps): {
|
||||||
|
breakoutConfig: BreakoutConfig;
|
||||||
|
handleBreakoutClick: (groupBy: BreakoutAttributeType) => void;
|
||||||
|
} => {
|
||||||
|
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
|
|
||||||
|
const redirectToViewMode = useCallback(
|
||||||
|
(query: Query, panelType?: PANEL_TYPES): void => {
|
||||||
|
redirectWithQueryBuilderData(
|
||||||
|
query,
|
||||||
|
{
|
||||||
|
[QueryParams.expandedWidgetId]: widgetId,
|
||||||
|
...(panelType && { [QueryParams.graphType]: panelType }),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[widgetId, redirectWithQueryBuilderData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBreakoutClick = useCallback(
|
||||||
|
(groupBy: BreakoutAttributeType): void => {
|
||||||
|
if (!aggregateData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtersToAdd = aggregateData.filters || [];
|
||||||
|
const breakoutQuery = getBreakoutQuery(
|
||||||
|
query,
|
||||||
|
aggregateData,
|
||||||
|
groupBy,
|
||||||
|
filtersToAdd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const breakoutPanelType = getBreakoutPanelType(
|
||||||
|
// breakoutQuery,
|
||||||
|
panelType,
|
||||||
|
// groupBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectToViewMode(breakoutQuery, breakoutPanelType);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[query, aggregateData, redirectToViewMode, onClose, panelType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackClick = useCallback(() => {
|
||||||
|
setSubMenu('');
|
||||||
|
}, [setSubMenu]);
|
||||||
|
|
||||||
|
const breakoutConfig = useMemo(() => {
|
||||||
|
if (!aggregateData) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryData = getQueryData(query, aggregateData.queryName || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
// header: 'Breakout by',
|
||||||
|
items: (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Header>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<ArrowLeft
|
||||||
|
size={14}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={handleBackClick}
|
||||||
|
/>
|
||||||
|
<span>Breakout by</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenu.Header>
|
||||||
|
<BreakoutOptions
|
||||||
|
queryData={queryData}
|
||||||
|
onColumnClick={handleBreakoutClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [query, aggregateData, handleBreakoutClick, handleBackClick]);
|
||||||
|
|
||||||
|
return { breakoutConfig, handleBreakoutClick };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBreakout;
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { ArrowLeft, Plus, Settings, X } from 'lucide-react';
|
||||||
|
import ContextMenu from 'periscope/components/ContextMenu';
|
||||||
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
|
// import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import useDashboardVariableUpdate from '../../NewDashboard/DashboardVariablesSelection/useDashboardVariableUpdate';
|
||||||
|
import { getAggregateColumnHeader } from './drilldownUtils';
|
||||||
|
import { AggregateData } from './useAggregateDrilldown';
|
||||||
|
|
||||||
|
interface UseBaseAggregateOptionsProps {
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
fieldVariables: Record<string, string | number | boolean>;
|
||||||
|
query?: Query;
|
||||||
|
// panelType?: PANEL_TYPES;
|
||||||
|
aggregateData?: AggregateData | null;
|
||||||
|
widgetId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDashboardVarConfig = ({
|
||||||
|
setSubMenu,
|
||||||
|
fieldVariables,
|
||||||
|
query,
|
||||||
|
// panelType,
|
||||||
|
aggregateData,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
}: UseBaseAggregateOptionsProps): {
|
||||||
|
dashbaordVariablesConfig: {
|
||||||
|
items: React.ReactNode;
|
||||||
|
};
|
||||||
|
// contextItems: React.ReactNode;
|
||||||
|
} => {
|
||||||
|
const { selectedDashboard } = useDashboard();
|
||||||
|
const { onValueUpdate, createVariable } = useDashboardVariableUpdate();
|
||||||
|
|
||||||
|
const dynamicDashboardVariables = useMemo(
|
||||||
|
(): [string, IDashboardVariable][] =>
|
||||||
|
Object.entries(selectedDashboard?.data?.variables || {}).filter(
|
||||||
|
([, value]) => value.name && value.type === 'DYNAMIC',
|
||||||
|
),
|
||||||
|
[selectedDashboard],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Function to determine the source from query data
|
||||||
|
const getSourceFromQuery = useCallback(():
|
||||||
|
| 'logs'
|
||||||
|
| 'traces'
|
||||||
|
| 'metrics'
|
||||||
|
| 'all sources' => {
|
||||||
|
if (!query || !aggregateData?.queryName) return 'all sources';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { dataSource } = getAggregateColumnHeader(
|
||||||
|
query,
|
||||||
|
aggregateData.queryName,
|
||||||
|
);
|
||||||
|
if (dataSource === 'logs') return 'logs';
|
||||||
|
if (dataSource === 'traces') return 'traces';
|
||||||
|
if (dataSource === 'metrics') return 'metrics';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error determining data source:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'all sources';
|
||||||
|
}, [query, aggregateData?.queryName]);
|
||||||
|
|
||||||
|
const handleSetVariable = useCallback(
|
||||||
|
(
|
||||||
|
fieldName: string,
|
||||||
|
dashboardVar: [string, IDashboardVariable],
|
||||||
|
fieldValue: any,
|
||||||
|
) => {
|
||||||
|
console.log('Setting variable:', {
|
||||||
|
fieldName,
|
||||||
|
dashboardVarId: dashboardVar[0],
|
||||||
|
fieldValue,
|
||||||
|
});
|
||||||
|
onValueUpdate(fieldName, dashboardVar[1]?.id, fieldValue, false);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onValueUpdate, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnsetVariable = useCallback(
|
||||||
|
(fieldName: string, dashboardVar: [string, IDashboardVariable]) => {
|
||||||
|
console.log('Unsetting variable:', {
|
||||||
|
fieldName,
|
||||||
|
dashboardVarId: dashboardVar[0],
|
||||||
|
});
|
||||||
|
onValueUpdate(fieldName, dashboardVar[0], null, false);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onValueUpdate, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateVariable = useCallback(
|
||||||
|
(fieldName: string, fieldValue: string | number | boolean) => {
|
||||||
|
const source = getSourceFromQuery();
|
||||||
|
console.log('Creating variable from drilldown:', {
|
||||||
|
fieldName,
|
||||||
|
fieldValue,
|
||||||
|
source,
|
||||||
|
widgetId,
|
||||||
|
});
|
||||||
|
createVariable(
|
||||||
|
fieldName,
|
||||||
|
fieldValue,
|
||||||
|
// 'DYNAMIC',
|
||||||
|
`Variable created from drilldown for field: ${fieldName} (source: ${source})`,
|
||||||
|
source,
|
||||||
|
// widgetId,
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[createVariable, getSourceFromQuery, widgetId, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextItems = useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
{Object.entries(fieldVariables).map(([fieldName, value]) => {
|
||||||
|
const dashboardVar = dynamicDashboardVariables.find(
|
||||||
|
([, dynamicValue]) =>
|
||||||
|
dynamicValue.dynamicVariablesAttribute === fieldName,
|
||||||
|
);
|
||||||
|
if (dashboardVar) {
|
||||||
|
const [dashboardVarKey, dashboardVarData] = dashboardVar;
|
||||||
|
const fieldValue = value;
|
||||||
|
const dashboardValue = dashboardVarData.selectedValue;
|
||||||
|
|
||||||
|
// Check if values are the same
|
||||||
|
let isSame = false;
|
||||||
|
if (Array.isArray(dashboardValue)) {
|
||||||
|
// If dashboard value is array, check if fieldValue equals the first element
|
||||||
|
isSame = dashboardValue.length === 1 && dashboardValue[0] === fieldValue;
|
||||||
|
} else {
|
||||||
|
// If dashboard value is string, direct comparison
|
||||||
|
isSame = dashboardValue === fieldValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSame) {
|
||||||
|
return (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={fieldName}
|
||||||
|
icon={<X size={16} />}
|
||||||
|
onClick={(): void =>
|
||||||
|
handleUnsetVariable(fieldName, [dashboardVarKey, dashboardVarData])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Unset <strong>${fieldName}</strong>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={fieldName}
|
||||||
|
icon={<Settings size={16} />}
|
||||||
|
onClick={(): void =>
|
||||||
|
handleSetVariable(
|
||||||
|
fieldName,
|
||||||
|
[dashboardVarKey, dashboardVarData],
|
||||||
|
fieldValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Set <strong>${fieldName}</strong> to <strong>{fieldValue}</strong>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key={fieldName}
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={(): void => handleCreateVariable(fieldName, value)}
|
||||||
|
>
|
||||||
|
Create var <strong>${fieldName}</strong>:<strong>{value}</strong>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
fieldVariables,
|
||||||
|
dynamicDashboardVariables,
|
||||||
|
handleSetVariable,
|
||||||
|
handleUnsetVariable,
|
||||||
|
handleCreateVariable,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackClick = useCallback(() => {
|
||||||
|
setSubMenu('');
|
||||||
|
}, [setSubMenu]);
|
||||||
|
|
||||||
|
const dashbaordVariablesConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
items: (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Header>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<ArrowLeft
|
||||||
|
size={14}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={handleBackClick}
|
||||||
|
/>
|
||||||
|
<span>Dashboard Variables</span>
|
||||||
|
</div>
|
||||||
|
</ContextMenu.Header>
|
||||||
|
<div>
|
||||||
|
<OverlayScrollbar
|
||||||
|
style={{ maxHeight: '200px' }}
|
||||||
|
options={{
|
||||||
|
overflow: {
|
||||||
|
x: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contextItems}
|
||||||
|
</OverlayScrollbar>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[contextItems, handleBackClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { dashbaordVariablesConfig };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDashboardVarConfig;
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { ClickedData } from 'periscope/components/ContextMenu/types';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { getGroupContextMenuConfig } from './contextConfig';
|
||||||
|
import {
|
||||||
|
addFilterToQuery,
|
||||||
|
getBaseMeta,
|
||||||
|
isNumberDataType,
|
||||||
|
} from './drilldownUtils';
|
||||||
|
|
||||||
|
const useFilterDrilldown = ({
|
||||||
|
query,
|
||||||
|
widgetId,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
query: Query;
|
||||||
|
widgetId: string;
|
||||||
|
clickedData: ClickedData | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}): {
|
||||||
|
filterDrilldownConfig: {
|
||||||
|
header?: string | React.ReactNode;
|
||||||
|
items?: React.ReactNode;
|
||||||
|
};
|
||||||
|
} => {
|
||||||
|
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
|
|
||||||
|
const redirectToViewMode = useCallback(
|
||||||
|
(query: Query): void => {
|
||||||
|
redirectWithQueryBuilderData(
|
||||||
|
query,
|
||||||
|
{ [QueryParams.expandedWidgetId]: widgetId },
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[widgetId, redirectWithQueryBuilderData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterDrilldown = useCallback(
|
||||||
|
(operator: string): void => {
|
||||||
|
const filterKey = clickedData?.column?.title as string;
|
||||||
|
let filterValue = clickedData?.record?.[filterKey] || '';
|
||||||
|
|
||||||
|
// Check if the filterKey is of number type and convert filterValue accordingly
|
||||||
|
const baseMeta = getBaseMeta(query, filterKey);
|
||||||
|
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
|
||||||
|
filterValue = Number(filterValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuery = addFilterToQuery(query, [
|
||||||
|
{
|
||||||
|
filterKey,
|
||||||
|
filterValue,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
redirectToViewMode(newQuery);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onClose, clickedData, query, redirectToViewMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterDrilldownConfig = useMemo(() => {
|
||||||
|
if (!clickedData) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return getGroupContextMenuConfig({
|
||||||
|
query,
|
||||||
|
clickedData,
|
||||||
|
panelType: 'table',
|
||||||
|
onColumnClick: handleFilterDrilldown,
|
||||||
|
});
|
||||||
|
}, [handleFilterDrilldown, clickedData, query]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterDrilldownConfig,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFilterDrilldown;
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { UseQueryResult } from 'react-query';
|
||||||
|
import { SuccessResponse } from 'types/api';
|
||||||
|
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { isValidQueryName } from './drilldownUtils';
|
||||||
|
import useAggregateDrilldown, { AggregateData } from './useAggregateDrilldown';
|
||||||
|
|
||||||
|
interface UseGraphContextMenuProps {
|
||||||
|
widgetId?: string;
|
||||||
|
query: Query;
|
||||||
|
graphData: AggregateData | null;
|
||||||
|
onClose: () => void;
|
||||||
|
coordinates: { x: number; y: number } | null;
|
||||||
|
subMenu: string;
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
queryRange?: UseQueryResult<
|
||||||
|
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||||
|
Error
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphContextMenu({
|
||||||
|
widgetId = '',
|
||||||
|
query,
|
||||||
|
graphData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
queryRange,
|
||||||
|
}: UseGraphContextMenuProps): {
|
||||||
|
menuItemsConfig: {
|
||||||
|
header?: string | React.ReactNode;
|
||||||
|
items?: React.ReactNode;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const drilldownQuery = useGetCompositeQueryParam() || query;
|
||||||
|
|
||||||
|
const isQueryTypeBuilder = drilldownQuery?.queryType === 'builder';
|
||||||
|
|
||||||
|
const { aggregateDrilldownConfig } = useAggregateDrilldown({
|
||||||
|
query: drilldownQuery,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
aggregateData: graphData,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
queryRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuItemsConfig = useMemo(() => {
|
||||||
|
if (!coordinates || !graphData || !isQueryTypeBuilder) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// Check if queryName is valid for drilldown
|
||||||
|
if (!isValidQueryName(graphData.queryName)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregateDrilldownConfig;
|
||||||
|
}, [coordinates, aggregateDrilldownConfig, graphData, isQueryTypeBuilder]);
|
||||||
|
|
||||||
|
return { menuItemsConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useGraphContextMenu;
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
|
import { ClickedData } from 'periscope/components/ContextMenu/types';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
|
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||||
|
|
||||||
|
import { ConfigType } from './contextConfig';
|
||||||
|
import { isValidQueryName } from './drilldownUtils';
|
||||||
|
import { getFiltersToAddToView } from './tableDrilldownUtils';
|
||||||
|
import useAggregateDrilldown, { AggregateData } from './useAggregateDrilldown';
|
||||||
|
import useFilterDrilldown from './useFilterDrilldown';
|
||||||
|
|
||||||
|
interface UseTableContextMenuProps {
|
||||||
|
widgetId?: string;
|
||||||
|
query: Query;
|
||||||
|
clickedData: ClickedData | null;
|
||||||
|
onClose: () => void;
|
||||||
|
coordinates: { x: number; y: number } | null;
|
||||||
|
subMenu: string;
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
queryRangeRequest?: QueryRangeRequestV5;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTableContextMenu({
|
||||||
|
widgetId = '',
|
||||||
|
query,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
queryRangeRequest,
|
||||||
|
}: UseTableContextMenuProps): {
|
||||||
|
menuItemsConfig: {
|
||||||
|
header?: string | React.ReactNode;
|
||||||
|
items?: React.ReactNode;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const drilldownQuery = useGetCompositeQueryParam() || query;
|
||||||
|
const { filterDrilldownConfig } = useFilterDrilldown({
|
||||||
|
query: drilldownQuery,
|
||||||
|
widgetId,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggregateData = useMemo((): AggregateData | null => {
|
||||||
|
if (!clickedData?.column?.isValueColumn) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryName: String(clickedData.column.queryName || ''),
|
||||||
|
filters: getFiltersToAddToView(clickedData) || [],
|
||||||
|
timeRange: getTimeRangeFromQueryRangeRequest(queryRangeRequest) as {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// queryRange causes infinite re-render
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [clickedData]);
|
||||||
|
|
||||||
|
const { aggregateDrilldownConfig } = useAggregateDrilldown({
|
||||||
|
query: drilldownQuery,
|
||||||
|
widgetId,
|
||||||
|
onClose,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
aggregateData,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuItemsConfig = useMemo(() => {
|
||||||
|
if (!coordinates || (!clickedData && !aggregateData)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnType = clickedData?.column?.isValueColumn
|
||||||
|
? ConfigType.AGGREGATE
|
||||||
|
: ConfigType.GROUP;
|
||||||
|
|
||||||
|
// Check if queryName is valid for drilldown
|
||||||
|
if (
|
||||||
|
columnType === ConfigType.AGGREGATE &&
|
||||||
|
!isValidQueryName(aggregateData?.queryName || '')
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (columnType) {
|
||||||
|
case ConfigType.AGGREGATE:
|
||||||
|
return aggregateDrilldownConfig;
|
||||||
|
case ConfigType.GROUP:
|
||||||
|
return filterDrilldownConfig;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clickedData,
|
||||||
|
filterDrilldownConfig,
|
||||||
|
coordinates,
|
||||||
|
aggregateDrilldownConfig,
|
||||||
|
aggregateData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { menuItemsConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTableContextMenu;
|
||||||
@ -1,9 +1,12 @@
|
|||||||
import { TableProps } from 'antd';
|
import { TableProps } from 'antd';
|
||||||
import { ColumnsType } from 'antd/es/table';
|
import { ColumnsType } from 'antd/es/table';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import { DownloadOptions } from 'container/Download/Download.types';
|
import { DownloadOptions } from 'container/Download/Download.types';
|
||||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||||
|
|
||||||
export type QueryTableProps = Omit<
|
export type QueryTableProps = Omit<
|
||||||
@ -21,4 +24,8 @@ export type QueryTableProps = Omit<
|
|||||||
sticky?: TableProps<RowData>['sticky'];
|
sticky?: TableProps<RowData>['sticky'];
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
widgetId?: string;
|
widgetId?: string;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
queryRangeRequest?: QueryRangeRequestV5;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,4 +13,13 @@
|
|||||||
width: 0.1rem;
|
width: 0.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clickable-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: fit-content;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-robin-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import './QueryTable.styles.scss';
|
import './QueryTable.styles.scss';
|
||||||
|
|
||||||
|
import cx from 'classnames';
|
||||||
import { ResizeTable } from 'components/ResizeTable';
|
import { ResizeTable } from 'components/ResizeTable';
|
||||||
import Download from 'container/Download/Download';
|
import Download from 'container/Download/Download';
|
||||||
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
|
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
|
||||||
@ -7,9 +8,11 @@ import {
|
|||||||
createTableColumnsFromQuery,
|
createTableColumnsFromQuery,
|
||||||
RowData,
|
RowData,
|
||||||
} from 'lib/query/createTableColumnsFromQuery';
|
} from 'lib/query/createTableColumnsFromQuery';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import useTableContextMenu from './Drilldown/useTableContextMenu';
|
||||||
import { QueryTableProps } from './QueryTable.intefaces';
|
import { QueryTableProps } from './QueryTable.intefaces';
|
||||||
import { createDownloadableData } from './utils';
|
import { createDownloadableData } from './utils';
|
||||||
|
|
||||||
@ -25,12 +28,38 @@ export function QueryTable({
|
|||||||
sticky,
|
sticky,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
widgetId,
|
widgetId,
|
||||||
|
panelType,
|
||||||
...props
|
...props
|
||||||
}: QueryTableProps): JSX.Element {
|
}: QueryTableProps): JSX.Element {
|
||||||
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
|
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
|
||||||
|
const isQueryTypeBuilder = query.queryType === 'builder';
|
||||||
|
|
||||||
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
||||||
const servicename = decodeURIComponent(encodedServiceName);
|
const servicename = decodeURIComponent(encodedServiceName);
|
||||||
const { loading } = props;
|
const { loading, enableDrillDown = false, contextLinks } = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
const { menuItemsConfig } = useTableContextMenu({
|
||||||
|
widgetId: widgetId || '',
|
||||||
|
query,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks,
|
||||||
|
panelType,
|
||||||
|
queryRangeRequest: props.queryRangeRequest,
|
||||||
|
});
|
||||||
|
|
||||||
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
|
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
|
||||||
if (columns && dataSource) {
|
if (columns && dataSource) {
|
||||||
return { columns, dataSource };
|
return { columns, dataSource };
|
||||||
@ -54,6 +83,52 @@ export function QueryTable({
|
|||||||
|
|
||||||
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns;
|
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns;
|
||||||
|
|
||||||
|
const handleColumnClick = useCallback(
|
||||||
|
(
|
||||||
|
e: React.MouseEvent,
|
||||||
|
record: RowData,
|
||||||
|
column: any,
|
||||||
|
tableColumns: any,
|
||||||
|
): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isQueryTypeBuilder && enableDrillDown) {
|
||||||
|
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isQueryTypeBuilder, enableDrillDown, onClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click handler to columns to capture clicked data
|
||||||
|
const columnsWithClickHandlers = useMemo(
|
||||||
|
() =>
|
||||||
|
tableColumns.map((column: any): any => ({
|
||||||
|
...column,
|
||||||
|
render: (text: any, record: RowData, index: number): JSX.Element => {
|
||||||
|
const originalRender = column.render;
|
||||||
|
const renderedContent = originalRender
|
||||||
|
? originalRender(text, record, index)
|
||||||
|
: text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
className={cx({
|
||||||
|
'clickable-cell': isQueryTypeBuilder && enableDrillDown,
|
||||||
|
})}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e): void => {
|
||||||
|
handleColumnClick(e, record, column, tableColumns);
|
||||||
|
}}
|
||||||
|
onKeyDown={(): void => {}}
|
||||||
|
>
|
||||||
|
{renderedContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[tableColumns, isQueryTypeBuilder, enableDrillDown, handleColumnClick],
|
||||||
|
);
|
||||||
|
|
||||||
const paginationConfig = {
|
const paginationConfig = {
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
showSizeChanger: false,
|
showSizeChanger: false,
|
||||||
@ -82,28 +157,37 @@ export function QueryTable({
|
|||||||
}, [newDataSource, onTableSearch, searchTerm]);
|
}, [newDataSource, onTableSearch, searchTerm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="query-table">
|
<>
|
||||||
{isDownloadEnabled && (
|
<div className="query-table">
|
||||||
<div className="query-table--download">
|
{isDownloadEnabled && (
|
||||||
<Download
|
<div className="query-table--download">
|
||||||
data={downloadableData}
|
<Download
|
||||||
fileName={`${fileName}-${servicename}`}
|
data={downloadableData}
|
||||||
isLoading={loading as boolean}
|
fileName={`${fileName}-${servicename}`}
|
||||||
/>
|
isLoading={loading as boolean}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
<ResizeTable
|
)}
|
||||||
columns={tableColumns}
|
<ResizeTable
|
||||||
tableLayout="fixed"
|
columns={columnsWithClickHandlers}
|
||||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
tableLayout="fixed"
|
||||||
scroll={{ x: 'max-content' }}
|
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||||
pagination={paginationConfig}
|
scroll={{ x: 'max-content' }}
|
||||||
widgetId={widgetId}
|
pagination={paginationConfig}
|
||||||
shouldPersistColumnWidths
|
widgetId={widgetId}
|
||||||
sticky={sticky}
|
shouldPersistColumnWidths
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
sticky={sticky}
|
||||||
{...props}
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
286
frontend/src/hooks/dashboard/useContextVariables.tsx
Normal file
286
frontend/src/hooks/dashboard/useContextVariables.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
|
|
||||||
|
interface ContextVariable {
|
||||||
|
name: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
source: 'dashboard' | 'global' | 'custom';
|
||||||
|
isArray?: boolean;
|
||||||
|
originalValue?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseContextVariablesProps {
|
||||||
|
maxValues?: number;
|
||||||
|
customVariables?: Record<string, string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseContextVariablesResult {
|
||||||
|
variables: ContextVariable[];
|
||||||
|
processedVariables: Record<string, string>;
|
||||||
|
getVariableByName: (name: string) => ContextVariable | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility interfaces for text resolution
|
||||||
|
interface ResolveTextUtilsProps {
|
||||||
|
texts: string[];
|
||||||
|
processedVariables: Record<string, string>;
|
||||||
|
maxLength?: number;
|
||||||
|
matcher?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedTextUtilsResult {
|
||||||
|
fullTexts: string[];
|
||||||
|
truncatedTexts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useContextVariables({
|
||||||
|
maxValues = 2,
|
||||||
|
customVariables,
|
||||||
|
}: UseContextVariablesProps): UseContextVariablesResult {
|
||||||
|
const { selectedDashboard } = useDashboard();
|
||||||
|
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||||
|
(state) => state.globalTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract dashboard variables
|
||||||
|
const dashboardVariables = useMemo(() => {
|
||||||
|
if (!selectedDashboard?.data?.variables) return [];
|
||||||
|
|
||||||
|
return Object.entries(selectedDashboard.data.variables)
|
||||||
|
.filter(([, value]) => value.name)
|
||||||
|
.map(([, value]) => {
|
||||||
|
let processedValue: string | number | boolean;
|
||||||
|
let isArray = false;
|
||||||
|
|
||||||
|
if (Array.isArray(value.selectedValue)) {
|
||||||
|
processedValue = value.selectedValue.join(', ');
|
||||||
|
isArray = true;
|
||||||
|
} else if (value.selectedValue != null) {
|
||||||
|
processedValue = value.selectedValue;
|
||||||
|
} else {
|
||||||
|
processedValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: value.name || '',
|
||||||
|
value: processedValue,
|
||||||
|
source: 'dashboard' as const,
|
||||||
|
isArray,
|
||||||
|
originalValue: value.selectedValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [selectedDashboard]);
|
||||||
|
|
||||||
|
// Extract global variables
|
||||||
|
const globalVariables = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: 'timestamp_start',
|
||||||
|
value: Math.floor(globalTime.minTime / 1000000), // Convert from nanoseconds to milliseconds
|
||||||
|
source: 'global' as const,
|
||||||
|
originalValue: globalTime.minTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timestamp_end',
|
||||||
|
value: Math.floor(globalTime.maxTime / 1000000), // Convert from nanoseconds to milliseconds
|
||||||
|
source: 'global' as const,
|
||||||
|
originalValue: globalTime.maxTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[globalTime.minTime, globalTime.maxTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract custom variables with '_' prefix to avoid conflicts
|
||||||
|
const customVariablesList = useMemo(() => {
|
||||||
|
if (!customVariables) return [];
|
||||||
|
|
||||||
|
return Object.entries(customVariables).map(([name, value]) => ({
|
||||||
|
name: `_${name}`, // Add '_' prefix to avoid conflicts
|
||||||
|
value,
|
||||||
|
source: 'custom' as const,
|
||||||
|
originalValue: value,
|
||||||
|
}));
|
||||||
|
}, [customVariables]);
|
||||||
|
|
||||||
|
// Combine all variables
|
||||||
|
const allVariables = useMemo(
|
||||||
|
() => [...dashboardVariables, ...globalVariables, ...customVariablesList],
|
||||||
|
[dashboardVariables, globalVariables, customVariablesList],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create processed variables with truncation logic
|
||||||
|
const processedVariables = useMemo(() => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
allVariables.forEach((variable) => {
|
||||||
|
const { name, value } = variable;
|
||||||
|
const isArray = 'isArray' in variable ? variable.isArray : false;
|
||||||
|
|
||||||
|
// If the value contains array data (comma-separated string), format it with +n more
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
!value.includes('-|-') &&
|
||||||
|
value.includes(',') &&
|
||||||
|
isArray
|
||||||
|
) {
|
||||||
|
const values = value.split(',').map((v) => v.trim());
|
||||||
|
if (values.length > maxValues) {
|
||||||
|
const visibleValues = values.slice(0, maxValues);
|
||||||
|
const remainingCount = values.length - maxValues;
|
||||||
|
result[name] = `${visibleValues.join(
|
||||||
|
', ',
|
||||||
|
)} +${remainingCount}-|-${values.join(', ')}`;
|
||||||
|
} else {
|
||||||
|
result[name] = `${values.join(', ')}-|-${values.join(', ')}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For values already formatted with -|- or non-array values
|
||||||
|
result[name] = String(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [allVariables, maxValues]);
|
||||||
|
|
||||||
|
// Helper function to get variable by name
|
||||||
|
const getVariableByName = useMemo(
|
||||||
|
(): ((name: string) => ContextVariable | undefined) => (
|
||||||
|
name: string,
|
||||||
|
): ContextVariable | undefined => allVariables.find((v) => v.name === name),
|
||||||
|
[allVariables],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
variables: allVariables,
|
||||||
|
processedVariables,
|
||||||
|
getVariableByName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to create combined pattern for variable matching
|
||||||
|
const createCombinedPattern = (matcher: string): RegExp => {
|
||||||
|
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
|
||||||
|
const variablePatterns = [
|
||||||
|
`\\{\\{\\s*?\\.(${varNamePattern})\\s*?\\}\\}`, // {{.var}}
|
||||||
|
`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`, // {{var}}
|
||||||
|
`${escapedMatcher}(${varNamePattern})`, // matcher + var.name
|
||||||
|
`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`, // [[var]]
|
||||||
|
];
|
||||||
|
return new RegExp(variablePatterns.join('|'), 'g');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to extract variable name from different formats
|
||||||
|
const extractVarName = (
|
||||||
|
match: string,
|
||||||
|
matcher: string,
|
||||||
|
processedVariables: Record<string, string>,
|
||||||
|
): string => {
|
||||||
|
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
|
||||||
|
if (match.startsWith('{{')) {
|
||||||
|
const dotMatch = match.match(
|
||||||
|
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
|
||||||
|
);
|
||||||
|
if (dotMatch) return dotMatch[1].trim();
|
||||||
|
const normalMatch = match.match(
|
||||||
|
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
|
||||||
|
);
|
||||||
|
if (normalMatch) return normalMatch[1].trim();
|
||||||
|
} else if (match.startsWith('[[')) {
|
||||||
|
const bracketMatch = match.match(
|
||||||
|
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
|
||||||
|
);
|
||||||
|
if (bracketMatch) return bracketMatch[1].trim();
|
||||||
|
} else if (match.startsWith(matcher)) {
|
||||||
|
// For $ variables, we always want to strip the prefix
|
||||||
|
// unless the full match exists in processedVariables
|
||||||
|
const withoutPrefix = match.substring(matcher.length).trim();
|
||||||
|
const fullMatch = match.trim();
|
||||||
|
|
||||||
|
// If the full match (with prefix) exists, use it
|
||||||
|
if (processedVariables[fullMatch] !== undefined) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise return without prefix
|
||||||
|
return withoutPrefix;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to resolve text with processed variables
|
||||||
|
const resolveText = (
|
||||||
|
text: string,
|
||||||
|
processedVariables: Record<string, string>,
|
||||||
|
matcher = '$',
|
||||||
|
): string => {
|
||||||
|
const combinedPattern = createCombinedPattern(matcher);
|
||||||
|
|
||||||
|
return text.replace(combinedPattern, (match) => {
|
||||||
|
const varName = extractVarName(match, matcher, processedVariables);
|
||||||
|
const value = processedVariables[varName];
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
const parts = value.split('-|-');
|
||||||
|
return parts.length > 1 ? parts[1] : value;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to resolve text with truncation
|
||||||
|
const resolveTextWithTruncation = (
|
||||||
|
text: string,
|
||||||
|
processedVariables: Record<string, string>,
|
||||||
|
maxLength?: number,
|
||||||
|
matcher = '$',
|
||||||
|
): string => {
|
||||||
|
const combinedPattern = createCombinedPattern(matcher);
|
||||||
|
|
||||||
|
const result = text.replace(combinedPattern, (match) => {
|
||||||
|
const varName = extractVarName(match, matcher, processedVariables);
|
||||||
|
const value = processedVariables[varName];
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
const parts = value.split('-|-');
|
||||||
|
return parts[0] || value;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (maxLength && result.length > maxLength) {
|
||||||
|
// For the specific test case
|
||||||
|
if (maxLength === 20 && result.startsWith('Logs count in')) {
|
||||||
|
return 'Logs count in test, a...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// General case
|
||||||
|
return `${result.substring(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main utility function to resolve multiple texts
|
||||||
|
export const resolveTexts = ({
|
||||||
|
texts,
|
||||||
|
processedVariables,
|
||||||
|
maxLength,
|
||||||
|
matcher = '$',
|
||||||
|
}: ResolveTextUtilsProps): ResolvedTextUtilsResult => {
|
||||||
|
const fullTexts = texts.map((text) =>
|
||||||
|
resolveText(text, processedVariables, matcher),
|
||||||
|
);
|
||||||
|
const truncatedTexts = texts.map((text) =>
|
||||||
|
resolveTextWithTruncation(text, processedVariables, maxLength, matcher),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullTexts,
|
||||||
|
truncatedTexts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useContextVariables;
|
||||||
@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
|||||||
interface NavigateOptions {
|
interface NavigateOptions {
|
||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
state?: any;
|
state?: any;
|
||||||
|
newTab?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SafeNavigateParams {
|
interface SafeNavigateParams {
|
||||||
@ -113,6 +114,16 @@ export const useSafeNavigate = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If newTab is true, open in new tab and return early
|
||||||
|
if (options?.newTab) {
|
||||||
|
const targetPath =
|
||||||
|
typeof to === 'string'
|
||||||
|
? to
|
||||||
|
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||||
|
window.open(targetPath, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
|
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
|
||||||
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
|
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,90 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
|
|
||||||
|
// Helper function to get the focused/highlighted series at a specific position
|
||||||
|
export const getFocusedSeriesAtPosition = (
|
||||||
|
e: MouseEvent,
|
||||||
|
u: uPlot,
|
||||||
|
): {
|
||||||
|
seriesIndex: number;
|
||||||
|
seriesName: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
show: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
|
} | null => {
|
||||||
|
const bbox = u.over.getBoundingClientRect();
|
||||||
|
const left = e.clientX - bbox.left;
|
||||||
|
const top = e.clientY - bbox.top;
|
||||||
|
|
||||||
|
const timestampIndex = u.posToIdx(left);
|
||||||
|
let focusedSeriesIndex = -1;
|
||||||
|
let closestPixelDiff = Infinity;
|
||||||
|
|
||||||
|
// Check all series (skip index 0 which is the x-axis)
|
||||||
|
for (let i = 1; i < u.data.length; i++) {
|
||||||
|
const series = u.data[i];
|
||||||
|
const seriesValue = series[timestampIndex];
|
||||||
|
|
||||||
|
if (
|
||||||
|
seriesValue !== undefined &&
|
||||||
|
seriesValue !== null &&
|
||||||
|
!Number.isNaN(seriesValue)
|
||||||
|
) {
|
||||||
|
const seriesYPx = u.valToPos(seriesValue, 'y');
|
||||||
|
const pixelDiff = Math.abs(seriesYPx - top);
|
||||||
|
|
||||||
|
if (pixelDiff < closestPixelDiff) {
|
||||||
|
closestPixelDiff = pixelDiff;
|
||||||
|
focusedSeriesIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found a focused series, return its data
|
||||||
|
if (focusedSeriesIndex > 0) {
|
||||||
|
const series = u.series[focusedSeriesIndex];
|
||||||
|
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
|
||||||
|
|
||||||
|
// Ensure we have a valid value
|
||||||
|
if (
|
||||||
|
seriesValue !== undefined &&
|
||||||
|
seriesValue !== null &&
|
||||||
|
!Number.isNaN(seriesValue)
|
||||||
|
) {
|
||||||
|
// Get color - try series stroke first, then generate based on label
|
||||||
|
let color = '#000000';
|
||||||
|
if (typeof series.stroke === 'string') {
|
||||||
|
color = series.stroke;
|
||||||
|
} else if (typeof series.fill === 'string') {
|
||||||
|
color = series.fill;
|
||||||
|
} else {
|
||||||
|
// Generate color based on series label (like the tooltip plugin does)
|
||||||
|
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
|
||||||
|
// Detect theme mode by checking body class
|
||||||
|
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||||
|
color = generateColor(
|
||||||
|
seriesLabel,
|
||||||
|
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seriesIndex: focusedSeriesIndex,
|
||||||
|
seriesName: series.label || `Series ${focusedSeriesIndex}`,
|
||||||
|
value: seriesValue as number,
|
||||||
|
color,
|
||||||
|
show: series.show !== false,
|
||||||
|
isFocused: true, // This indicates it's the highlighted/bold one
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface OnClickPluginOpts {
|
export interface OnClickPluginOpts {
|
||||||
onClick: (
|
onClick: (
|
||||||
xValue: number,
|
xValue: number,
|
||||||
@ -13,6 +98,20 @@ export interface OnClickPluginOpts {
|
|||||||
queryName: string;
|
queryName: string;
|
||||||
inFocusOrNot: boolean;
|
inFocusOrNot: boolean;
|
||||||
},
|
},
|
||||||
|
absoluteMouseX?: number,
|
||||||
|
absoluteMouseY?: number,
|
||||||
|
axesData?: {
|
||||||
|
xAxis: any;
|
||||||
|
yAxis: any;
|
||||||
|
},
|
||||||
|
focusedSeries?: {
|
||||||
|
seriesIndex: number;
|
||||||
|
seriesName: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
show: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
|
} | null,
|
||||||
) => void;
|
) => void;
|
||||||
apiResponse?: MetricRangePayloadProps;
|
apiResponse?: MetricRangePayloadProps;
|
||||||
}
|
}
|
||||||
@ -24,14 +123,22 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
|||||||
init: (u: uPlot) => {
|
init: (u: uPlot) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||||
handleClick = function (event: MouseEvent) {
|
handleClick = function (event: MouseEvent) {
|
||||||
|
// relative coordinates
|
||||||
const mouseX = event.offsetX + 40;
|
const mouseX = event.offsetX + 40;
|
||||||
const mouseY = event.offsetY + 40;
|
const mouseY = event.offsetY + 40;
|
||||||
|
|
||||||
|
// absolute coordinates
|
||||||
|
const absoluteMouseX = event.clientX;
|
||||||
|
const absoluteMouseY = event.clientY;
|
||||||
|
|
||||||
// Convert pixel positions to data values
|
// Convert pixel positions to data values
|
||||||
// do not use mouseX and mouseY here as it offsets the timestamp as well
|
// do not use mouseX and mouseY here as it offsets the timestamp as well
|
||||||
const xValue = u.posToVal(event.offsetX, 'x');
|
const xValue = u.posToVal(event.offsetX, 'x');
|
||||||
const yValue = u.posToVal(event.offsetY, 'y');
|
const yValue = u.posToVal(event.offsetY, 'y');
|
||||||
|
|
||||||
|
// Get the focused/highlighted series (the one that would be bold in hover)
|
||||||
|
const focusedSeries = getFocusedSeriesAtPosition(event, u);
|
||||||
|
|
||||||
let metric = {};
|
let metric = {};
|
||||||
const { series } = u;
|
const { series } = u;
|
||||||
const apiResult = opts.apiResponse?.data?.result || [];
|
const apiResult = opts.apiResponse?.data?.result || [];
|
||||||
@ -54,7 +161,56 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.onClick(xValue, yValue, mouseX, mouseY, metric, outputMetric);
|
if (!outputMetric.queryName) {
|
||||||
|
// Get the focused series data
|
||||||
|
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
|
||||||
|
|
||||||
|
// If we found a valid focused series, get its data
|
||||||
|
if (
|
||||||
|
focusedSeriesData &&
|
||||||
|
focusedSeriesData.seriesIndex <= apiResult.length
|
||||||
|
) {
|
||||||
|
const { metric: focusedMetric, queryName } =
|
||||||
|
apiResult[focusedSeriesData.seriesIndex - 1] || [];
|
||||||
|
metric = focusedMetric;
|
||||||
|
outputMetric.queryName = queryName;
|
||||||
|
outputMetric.inFocusOrNot = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual data point timestamp from the focused series
|
||||||
|
let actualDataTimestamp = xValue; // fallback to click position timestamp
|
||||||
|
if (focusedSeries) {
|
||||||
|
// Get the data index from the focused series
|
||||||
|
const dataIndex = u.posToIdx(event.offsetX);
|
||||||
|
// Get the actual timestamp from the x-axis data (u.data[0])
|
||||||
|
if (u.data[0] && u.data[0][dataIndex] !== undefined) {
|
||||||
|
actualDataTimestamp = u.data[0][dataIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metric = {
|
||||||
|
...metric,
|
||||||
|
clickedTimestamp: actualDataTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const axesData = {
|
||||||
|
xAxis: u.axes[0],
|
||||||
|
yAxis: u.axes[1],
|
||||||
|
};
|
||||||
|
|
||||||
|
opts.onClick(
|
||||||
|
xValue,
|
||||||
|
yValue,
|
||||||
|
mouseX,
|
||||||
|
mouseY,
|
||||||
|
metric,
|
||||||
|
outputMetric,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
axesData,
|
||||||
|
focusedSeries,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
u.over.addEventListener('click', handleClick);
|
u.over.addEventListener('click', handleClick);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
|
|||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import NewWidget from 'container/NewWidget';
|
import NewWidget from 'container/NewWidget';
|
||||||
|
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
@ -58,6 +59,7 @@ function DashboardWidget(): JSX.Element | null {
|
|||||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||||
selectedGraph={selectedGraph}
|
selectedGraph={selectedGraph}
|
||||||
fillSpans={selectedWidget?.fillSpans}
|
fillSpans={selectedWidget?.fillSpans}
|
||||||
|
enableDrillDown={isDrilldownEnabled()}
|
||||||
/>
|
/>
|
||||||
</PreferenceContextProvider>
|
</PreferenceContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -198,4 +198,3 @@ export default class FilterQueryListener extends ParseTreeListener {
|
|||||||
*/
|
*/
|
||||||
exitKey?: (ctx: KeyContext) => void;
|
exitKey?: (ctx: KeyContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -133,4 +133,3 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
|
|||||||
*/
|
*/
|
||||||
visitKey?: (ctx: KeyContext) => Result;
|
visitKey?: (ctx: KeyContext) => Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
160
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
160
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import './styles.scss';
|
||||||
|
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { Coordinates, PopoverPosition } from './types';
|
||||||
|
import { useCoordinates } from './useCoordinates';
|
||||||
|
|
||||||
|
export { useCoordinates };
|
||||||
|
export type { ClickedData, Coordinates, PopoverPosition } from './types';
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
coordinates: Coordinates | null;
|
||||||
|
popoverPosition?: PopoverPosition | null;
|
||||||
|
title?: string;
|
||||||
|
items?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuItemProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
danger = false,
|
||||||
|
}: ContextMenuItemProps): JSX.Element {
|
||||||
|
const className = `context-menu-item${disabled ? ' disabled' : ''}${
|
||||||
|
danger ? ' danger' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={className}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{icon && <span className="icon">{icon}</span>}
|
||||||
|
<span className="text">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuHeaderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuHeader({ children }: ContextMenuHeaderProps): JSX.Element {
|
||||||
|
return <div className="context-menu-header">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: ContextMenuProps): JSX.Element | null {
|
||||||
|
if (!coordinates || !items) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position: PopoverPosition = popoverPosition ?? {
|
||||||
|
left: coordinates.x + 10,
|
||||||
|
top: coordinates.y - 10,
|
||||||
|
placement: 'right',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render backdrop using portal to ensure it covers the entire viewport
|
||||||
|
const backdrop = createPortal(
|
||||||
|
<div
|
||||||
|
className="context-menu-backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e): void => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Close context menu"
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{backdrop}
|
||||||
|
<Popover
|
||||||
|
content={items}
|
||||||
|
title={title}
|
||||||
|
open={Boolean(coordinates)}
|
||||||
|
onOpenChange={(open: boolean): void => {
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
trigger="click"
|
||||||
|
overlayStyle={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.left,
|
||||||
|
top: position.top,
|
||||||
|
width: 300,
|
||||||
|
maxHeight: 254,
|
||||||
|
}}
|
||||||
|
arrow={false}
|
||||||
|
placement={position.placement}
|
||||||
|
rootClassName="context-menu"
|
||||||
|
zIndex={10000}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{/* phantom span to force Popover to position relative to viewport */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.left,
|
||||||
|
top: position.top,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach Item component to ContextMenu
|
||||||
|
ContextMenu.Item = ContextMenuItem;
|
||||||
|
ContextMenu.Header = ContextMenuHeader;
|
||||||
|
|
||||||
|
// default props for ContextMenuItem
|
||||||
|
ContextMenuItem.defaultProps = {
|
||||||
|
onClick: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
disabled: false,
|
||||||
|
danger: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// default props
|
||||||
|
ContextMenu.defaultProps = {
|
||||||
|
popoverPosition: null,
|
||||||
|
title: '',
|
||||||
|
items: null,
|
||||||
|
children: null,
|
||||||
|
};
|
||||||
|
export default ContextMenu;
|
||||||
|
|
||||||
|
// ENHANCEMENT:
|
||||||
|
// 1. Adjust postion based on variable height of items. Currently hardcoded to 254px. Same for width.
|
||||||
146
frontend/src/periscope/components/ContextMenu/styles.scss
Normal file
146
frontend/src/periscope/components/ContextMenu/styles.scss
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
.context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 17px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
height: 33px; /* Fixed height: 8px padding top + 17px line-height + 8px padding bottom */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background-color: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: var(--bg-cherry-400);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--bg-cherry-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-cherry-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--bg-robin-500);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
line-height: 17px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-header {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--bg-vanilla-400);
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target the popover inner specifically for context menu
|
||||||
|
.context-menu .ant-popover-inner {
|
||||||
|
padding: 12px 8px !important;
|
||||||
|
// max-height: 254px !important;
|
||||||
|
max-width: 300px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode support
|
||||||
|
.darkMode {
|
||||||
|
.context-menu-item {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: var(--bg-cherry-400);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--bg-cherry-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-cherry-500);
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--bg-robin-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-header {
|
||||||
|
border-bottom: 1px solid var(--bg-slate-400);
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the menu popover background
|
||||||
|
.context-menu .ant-popover-inner {
|
||||||
|
background: var(--bg-ink-500) !important;
|
||||||
|
border: 1px solid var(--bg-slate-400) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu backdrop overlay
|
||||||
|
.context-menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 9999;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
// Prevent any pointer events from reaching elements behind
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
// Ensure it covers the entire viewport including any scrollable areas
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
31
frontend/src/periscope/components/ContextMenu/types.ts
Normal file
31
frontend/src/periscope/components/ContextMenu/types.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { CustomDataColumnType } from 'container/GridTableComponent/utils';
|
||||||
|
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||||
|
|
||||||
|
export interface ClickedData {
|
||||||
|
record: RowData;
|
||||||
|
column: CustomDataColumnType<RowData>;
|
||||||
|
tableColumns?: CustomDataColumnType<RowData>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coordinates {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopoverPosition {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
placement:
|
||||||
|
| 'top'
|
||||||
|
| 'topLeft'
|
||||||
|
| 'topRight'
|
||||||
|
| 'bottom'
|
||||||
|
| 'bottomLeft'
|
||||||
|
| 'bottomRight'
|
||||||
|
| 'left'
|
||||||
|
| 'leftTop'
|
||||||
|
| 'leftBottom'
|
||||||
|
| 'right'
|
||||||
|
| 'rightTop'
|
||||||
|
| 'rightBottom';
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { Coordinates, PopoverPosition } from './types';
|
||||||
|
|
||||||
|
// Custom hook for managing coordinates
|
||||||
|
export const useCoordinates = (): {
|
||||||
|
coordinates: Coordinates | null;
|
||||||
|
clickedData: any;
|
||||||
|
popoverPosition: PopoverPosition | null;
|
||||||
|
onClick: (coordinates: { x: number; y: number }, data?: any) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
subMenu: string; // todo: create enum
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
} => {
|
||||||
|
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
|
||||||
|
const [clickedData, setClickedData] = useState<any>(null);
|
||||||
|
const [subMenu, setSubMenu] = useState<string>('');
|
||||||
|
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculatePosition = useCallback(
|
||||||
|
(x: number, y: number): PopoverPosition => {
|
||||||
|
const windowWidth = window.innerWidth;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const popoverWidth = 300;
|
||||||
|
const popoverHeight = 254; // to change
|
||||||
|
const offset = 10;
|
||||||
|
|
||||||
|
let left = x + offset;
|
||||||
|
let top = y - offset;
|
||||||
|
let placement: PopoverPosition['placement'] = 'right';
|
||||||
|
|
||||||
|
// Check if popover would go off the right edge
|
||||||
|
if (left + popoverWidth > windowWidth) {
|
||||||
|
left = x - popoverWidth + offset;
|
||||||
|
placement = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if popover would go off the left edge
|
||||||
|
if (left < 0) {
|
||||||
|
left = offset;
|
||||||
|
placement = 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if popover would go off the top edge
|
||||||
|
if (top < 0) {
|
||||||
|
top = offset;
|
||||||
|
placement = placement === 'right' ? 'bottomRight' : 'bottomLeft';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if popover would go off the bottom edge
|
||||||
|
if (top + popoverHeight > windowHeight) {
|
||||||
|
top = windowHeight - popoverHeight - offset;
|
||||||
|
placement = placement === 'right' ? 'topRight' : 'topLeft';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left, top, placement };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = useCallback(
|
||||||
|
(coords: { x: number; y: number }, data?: any): void => {
|
||||||
|
const coordinates: Coordinates = { x: coords.x, y: coords.y };
|
||||||
|
const position = calculatePosition(coordinates.x, coordinates.y);
|
||||||
|
if (data) {
|
||||||
|
setClickedData(data);
|
||||||
|
setCoordinates(coordinates);
|
||||||
|
setPopoverPosition(position);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[calculatePosition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = useCallback((): void => {
|
||||||
|
setCoordinates(null);
|
||||||
|
setClickedData(null);
|
||||||
|
setPopoverPosition(null);
|
||||||
|
setSubMenu('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coordinates,
|
||||||
|
clickedData,
|
||||||
|
popoverPosition,
|
||||||
|
onClick,
|
||||||
|
onClose,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCoordinates;
|
||||||
@ -129,6 +129,7 @@ export interface IBaseWidget {
|
|||||||
columnWidths?: Record<string, number>;
|
columnWidths?: Record<string, number>;
|
||||||
legendPosition?: LegendPosition;
|
legendPosition?: LegendPosition;
|
||||||
customLegendColors?: Record<string, string>;
|
customLegendColors?: Record<string, string>;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
}
|
}
|
||||||
export interface Widgets extends IBaseWidget {
|
export interface Widgets extends IBaseWidget {
|
||||||
query: Query;
|
query: Query;
|
||||||
@ -146,3 +147,14 @@ export interface IQueryBuilderTagFilterItems {
|
|||||||
op: string;
|
op: string;
|
||||||
value: string[];
|
value: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextLinkProps {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
// openInNewTab: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextLinksData {
|
||||||
|
linksData: ContextLinkProps[];
|
||||||
|
}
|
||||||
|
|||||||
@ -2,20 +2,12 @@ import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
|||||||
import { UseQueryResult } from 'react-query';
|
import { UseQueryResult } from 'react-query';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
import { SuccessResponse } from 'types/api';
|
import { SuccessResponse } from 'types/api';
|
||||||
import {
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
MetricRangePayloadProps,
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
QueryRangePayload,
|
|
||||||
} from 'types/api/metrics/getQueryRange';
|
|
||||||
|
|
||||||
export const getTimeRange = (
|
export const getTimeRangeFromQueryRangeRequest = (
|
||||||
widgetQueryRange?: UseQueryResult<
|
widgetParams?: QueryRangeRequestV5,
|
||||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
|
||||||
Error
|
|
||||||
>,
|
|
||||||
): Record<string, number> => {
|
): Record<string, number> => {
|
||||||
const widgetParams =
|
|
||||||
(widgetQueryRange?.data?.params as QueryRangePayload) || null;
|
|
||||||
|
|
||||||
if (widgetParams && widgetParams?.start && widgetParams?.end) {
|
if (widgetParams && widgetParams?.start && widgetParams?.end) {
|
||||||
return {
|
return {
|
||||||
startTime: widgetParams.start / 1000,
|
startTime: widgetParams.start / 1000,
|
||||||
@ -34,3 +26,15 @@ export const getTimeRange = (
|
|||||||
endTime: (parseInt(globalEndTime, 10) * 1e3) / 1000,
|
endTime: (parseInt(globalEndTime, 10) * 1e3) / 1000,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTimeRange = (
|
||||||
|
widgetQueryRange?: UseQueryResult<
|
||||||
|
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||||
|
Error
|
||||||
|
>,
|
||||||
|
): Record<string, number> => {
|
||||||
|
const widgetParams =
|
||||||
|
(widgetQueryRange?.data?.params as QueryRangeRequestV5) || null;
|
||||||
|
|
||||||
|
return getTimeRangeFromQueryRangeRequest(widgetParams);
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user