Compare commits

...

117 Commits

Author SHA1 Message Date
Aditya Singh
f8b16e1034 feat: minor refactor 2025-08-05 22:45:15 +05:30
Aditya Singh
749dff2200 feat: minor refactor 2025-08-05 20:03:33 +05:30
Aditya Singh
de05394859 feat: fix header color 2025-08-05 17:56:41 +05:30
Aditya Singh
a6a9bf5bad Merge branch 'feat/drilldowns' of github.com:SigNoz/signoz into feat/drilldowns 2025-08-05 17:37:50 +05:30
Aditya Singh
e767c229aa Merge branch 'main' of github.com:SigNoz/signoz into feat/drilldowns 2025-08-05 17:37:31 +05:30
Aditya Singh
b9cf516201 feat: aggregation header val 2025-08-05 17:30:34 +05:30
Aditya Singh
f87e80a0f5 Merge branch 'main' into feat/drilldowns 2025-08-05 13:41:29 +05:30
Aditya Singh
f114d0249d feat: revert qbv5 2025-08-05 13:32:35 +05:30
Aditya Singh
b4fbd7c673 feat: snapshot update 2025-08-05 12:01:30 +05:30
Aditya Singh
e25d625c4b feat: minor refactor 2025-08-05 11:39:22 +05:30
Aditya Singh
9ca0cc90b0 Merge branch 'main' of github.com:SigNoz/signoz into feat/drilldowns 2025-08-04 19:58:31 +05:30
Aditya Singh
90758dbd32 feat: context menu hook refactor 2025-08-01 15:11:51 +05:30
Aditya Singh
9b559d6251 feat: context menu - increase width and add overlay 2025-07-19 15:16:47 +05:30
Aditya Singh
bdfb712395 feat: add search to breakout and other refactor 2025-07-19 14:39:55 +05:30
Aditya Singh
0d2a4b397a feat: lint fix 2025-07-17 02:03:59 +05:30
Aditya Singh
2c9a51c2ac feat: update click plugin in uplot 2025-07-17 01:43:24 +05:30
Aditya Singh
fb43f12a76 feat: refactor code 2025-07-17 01:05:37 +05:30
Aditya Singh
60e0e84237 feat: drilldown prop drilldowned 2025-07-16 20:29:29 +05:30
Aditya Singh
54d46a1d03 feat: minor refactor 2025-07-16 20:05:26 +05:30
Aditya Singh
73a7246a11 feat: remove unwanted code 2025-07-16 19:47:23 +05:30
Aditya Singh
163d59bf71 feat: add time range to timeseries, bar charts 2025-07-16 17:04:40 +05:30
Aditya Singh
fb672eda11 feat: add drilldown options in uplot 2025-07-16 02:46:42 +05:30
Aditya Singh
43a432b22b feat: add drilldown options in pie chart 2025-07-16 02:20:21 +05:30
Aditya Singh
8107946cb1 feat: added click data utils for uplot and pie charts 2025-07-16 02:16:39 +05:30
Aditya Singh
38ee4aae30 feat: add graph context hook 2025-07-16 02:09:27 +05:30
Aditya Singh
001d9ed9fb feat: fix style 2025-07-16 02:08:44 +05:30
Aditya Singh
e1abae91a3 feat: aggreagate drilldown refactor to use for tables and other panels alike 2025-07-15 20:49:31 +05:30
Aditya Singh
a9ac3b7e15 feat: aggreagate drilldown refactor to use for tables and other panels alike 2025-07-15 19:29:26 +05:30
Aditya Singh
4a98c54e78 feat: minor refactor 2025-07-14 18:06:00 +05:30
Aditya Singh
9ed4a09caf feat: update coordinates fn signature 2025-07-14 16:14:09 +05:30
Aditya Singh
132a31852f fix: remove number data type conversion 2025-07-14 15:08:13 +05:30
Aditya Singh
5686697b6c feat: fix aggreagate context header 2025-07-09 02:43:35 +05:30
Aditya Singh
5f4fc12031 feat: fix datatype 2025-07-09 02:09:24 +05:30
Aditya Singh
fe2c42de90 feat: hide drilldown for non-builder queries 2025-07-09 01:43:25 +05:30
Aditya Singh
d8f2cf1c0e feat: fix metrics view 2025-07-09 01:11:12 +05:30
Aditya Singh
a7e8f31561 feat: style fix 2025-07-08 21:29:58 +05:30
Aditya Singh
d9d6e7b4f1 feat: show reset query 2025-07-08 19:37:43 +05:30
Aditya Singh
f8f1a26a43 feat: style fix 2025-07-08 18:57:19 +05:30
Aditya Singh
79dfd6f17f feat: breakout drilldown option added 2025-07-08 15:31:13 +05:30
Aditya Singh
f386662e00 feat: aggregate col drilldown added 2025-07-03 18:51:36 +05:30
Aditya Singh
b2de302262 feat: context menu config refactor 2025-07-03 14:29:43 +05:30
Aditya Singh
6f63076b8e feat: context menu style fix 2025-07-03 01:24:34 +05:30
Aditya Singh
8007f954e5 feat: use context menu item for filters 2025-07-03 00:54:58 +05:30
Aditya Singh
b39b24c46f feat: context menu style update 2025-07-03 00:53:54 +05:30
Aditya Singh
70472c587d feat: filter drilldown added 2025-07-02 16:02:13 +05:30
Aditya Singh
06e89b7199 feat: added context menu 2025-06-27 11:26:00 +05:30
Aditya Singh
d60ac0d0e1 fix: fix composite query delete on close 2025-06-27 11:25:24 +05:30
Aditya Singh
1e4c213df4 feat: view mode enhancements 2025-06-27 11:24:47 +05:30
SagarRajput-7
9bf112cfcf Merge branch 'main' into feat/query-builder-v2 2025-06-25 16:19:10 +05:30
SagarRajput-7
a611b8f429 feat: new query builder misc fixes (#8359)
* feat: qb fixes

* feat: fixed handlerunquery props

* feat: fixes logs list order by

* feat: fix logs order by issue

* feat: safety check and order by correction

* feat: updated version in new create dashboards

* feat: added new formatOptions for table and fixed the pie chart plotting

* feat: keyboard shortcut overriding issue and pie ch correction in dashboard views

* feat: fixed dashboard data state management across datasource * paneltypes

* feat: fixed explorer pages data management issues

* feat: integrated new backend payload/request diff, to the UI types

* feat: fixed the collapse behaviour of QB - queries

* feat: fix order by and default aggregation to count()
2025-06-25 16:18:15 +05:30
SagarRajput-7
872230169c feat: resolved conflicts 2025-06-25 05:26:52 +05:30
SagarRajput-7
4a28954074 Query builder misc - fixes (#8295)
* feat: trace and logs explorer fixes

* fix: ui fixes

* fix: handle multi arg aggregation

* feat: explorer pages fixes

* feat: added fixes for order by for datasource

* feat: metric order by issue

* feat: support for paneltype selectedview tab switch

* feat: qb v2 compatiblity with url's composite query

* feat: conversion fixes

* feat: where clause and aggregation fix

---------

Co-authored-by: Yunus M <myounis.ar@live.com>
2025-06-25 05:17:57 +05:30
Yunus M
0df2d9e6da feat: fetch more keys is complete list not already fetched 2025-06-25 05:16:45 +05:30
SagarRajput-7
67f412477c feat: query_range migration from v3/v4 -> v5 (#8192)
* feat: query_range migration from v3/v4 -> v5

* feat: cleanup files

* feat: cleanup code

* feat: metric payload improvements

* feat: metric payload improvements

* feat: data retention and qb v2 for dashboard cleanup

* feat: corrected datasource change daata updatation in qb v2

* feat: fix value panel plotting with new query v5

* feat: alert migration

* feat: fixed aggregation css

* feat: explorer pages migration

* feat: trace and logs explorer fixes
2025-06-25 05:16:45 +05:30
Yunus M
43dc060950 fix: responsiveness issues 2025-06-25 05:16:45 +05:30
Yunus M
a21ae43a1f feat: where clause key updates 2025-06-25 05:16:45 +05:30
Yunus M
331a8b386f feat: update styles for light mode 2025-06-25 05:16:45 +05:30
Yunus M
ca6c7afa5c feat: show errors 2025-06-25 05:16:45 +05:30
Yunus M
dc8e5d6df9 feat: update context and show suggestions on select 2025-06-25 05:16:45 +05:30
Yunus M
c68f352aeb feat: add a space after selecting a value from suggestion 2025-06-25 05:16:45 +05:30
Yunus M
7863877a49 feat: improve suggestion ux in query search 2025-06-25 05:16:45 +05:30
Yunus M
76384c2430 feat: ui improvements 2025-06-25 05:16:45 +05:30
Yunus M
4e06d7757b feat: handle close on blur 2025-06-25 05:16:45 +05:30
Yunus M
5c06429ebe feat: query search component clean up 2025-06-25 05:16:45 +05:30
Yunus M
aefc7940a7 feat: handle having option autocomplete ux 2025-06-25 05:16:45 +05:30
Yunus M
0deae0c73b feat: disable clicking on placeholder items in suggestions 2025-06-25 05:16:45 +05:30
Yunus M
a4c16e5847 feat: improve having suggestions 2025-06-25 05:16:45 +05:30
Yunus M
efb741cf35 feat: handle add ons 2025-06-25 05:16:45 +05:30
Yunus M
153f64067c feat: handle list panel type options 2025-06-25 05:16:45 +05:30
Yunus M
c83ae1a485 feat: pass index to query addons 2025-06-25 05:16:45 +05:30
Yunus M
bfd74fb906 feat: update qb elements based on panel type 2025-06-25 05:16:45 +05:30
Yunus M
5d56f05fab feat: hide extra qb elements 2025-06-25 05:16:45 +05:30
Yunus M
57ca53c74c feat: use qb-v2 in explorers and alerts 2025-06-25 05:16:42 +05:30
Yunus M
bde078472b feat: update explorer views 2025-06-25 05:16:09 +05:30
Yunus M
6deb75ff46 feat: update logs, metrics and traces qb 2025-06-25 05:10:59 +05:30
Yunus M
424fd0362d feat: query builder layout updates 2025-06-25 05:10:59 +05:30
Yunus M
1bc51102f6 fix: minor fixes 2025-06-25 05:10:58 +05:30
Yunus M
c1b70c05f1 feat: create separate containers for traces, logs and metrics qbs 2025-06-25 05:10:58 +05:30
Yunus M
8fce0ab1af feat: metrics qb 2025-06-25 05:10:57 +05:30
Yunus M
df1923a7c6 fix: update dropdown css 2025-06-25 05:10:05 +05:30
Yunus M
1e37ae2fd0 feat: remove () from suggestions 2025-06-25 05:10:05 +05:30
Yunus M
7b3ea5cc45 feat: handle parenthesis and conjunction operators 2025-06-25 05:10:05 +05:30
Yunus M
167ddc6c56 feat: support multiple having key value pairs 2025-06-25 05:10:05 +05:30
Yunus M
dbc1e1fc45 feat: move state to context 2025-06-25 05:10:05 +05:30
Yunus M
01e798f3c1 feat: handle having options creation 2025-06-25 05:10:05 +05:30
Yunus M
d9010fb3fc feat: hide already used variables 2025-06-25 05:10:05 +05:30
Yunus M
06363f2e5b fix: show operator suggestions only on manual trigger or valid key 2025-06-25 05:10:05 +05:30
Yunus M
f1853a6bca fix: handle autocomplete 2025-06-25 05:10:05 +05:30
Yunus M
97e9f5dc8d fix: update styles 2025-06-25 05:10:05 +05:30
Yunus M
3b959bd2f6 fix: update css 2025-06-25 05:10:05 +05:30
Yunus M
9662e43418 feat: handle multie select functions 2025-06-25 05:10:05 +05:30
Yunus M
736bb2ebfb feat: handle field suggestions for aggregate operators 2025-06-25 05:10:05 +05:30
Yunus M
879700ea7a feat: support aggregation function with values 2025-06-25 05:10:05 +05:30
Yunus M
438ffe45f2 feat: add groupBy, having, order by, limit and legend format 2025-06-25 05:10:05 +05:30
Yunus M
723b6b6b79 feat: handle multie select values better 2025-06-25 05:10:05 +05:30
Yunus M
d2df098bb3 feat: improve suggestions 2025-06-25 05:10:05 +05:30
Yunus M
196ae10f00 feat: console log context based on cursor position 2025-06-25 05:10:05 +05:30
Yunus M
00eba89e20 fix: handle . notation keywords better 2025-06-25 05:10:05 +05:30
Yunus M
1739a9e27b feat: remove card container above where clause 2025-06-25 05:10:05 +05:30
Yunus M
cfdf714ffa feat: use new qb in logs explorer 2025-06-25 05:10:04 +05:30
Yunus M
49e78b6998 feat: handle parenthesis 2025-06-25 05:10:04 +05:30
Yunus M
762c658c10 feat: handle value selection 2025-06-25 05:10:04 +05:30
Yunus M
48e7e33dea feat: styling updates 2025-06-25 05:10:04 +05:30
Yunus M
dc4996c127 feat: handle string and number values correctly 2025-06-25 05:10:04 +05:30
Yunus M
d95f7b976c feat: handle async value fetching 2025-06-25 05:10:04 +05:30
Yunus M
9a47883064 feat: update the context with additonal properties 2025-06-25 05:10:04 +05:30
Yunus M
39a90fd33c feat: styling updates 2025-06-25 05:10:04 +05:30
Yunus M
722c3482d2 feat: update theme and syntax highlighting 2025-06-25 05:10:04 +05:30
Yunus M
60e84e6681 feat: handle context switch 2025-06-25 05:10:04 +05:30
Yunus M
8d1fa84e6a feat: handle multiple spaces 2025-06-25 05:10:04 +05:30
Yunus M
6c22197bf4 feat: integrate the apis 2025-06-25 05:10:04 +05:30
Yunus M
f6c426d0cc feat: update context logic and return auto-suggestions based on context 2025-06-25 05:10:04 +05:30
Yunus M
e21757b2bd feat: add apis and hooks 2025-06-25 05:10:04 +05:30
Yunus M
a87fbabbe7 feat: update context to recognise conjunction operator 2025-06-25 05:10:04 +05:30
Yunus M
b2847cb05b feat: add codemirror 2025-06-25 05:10:00 +05:30
Yunus M
0b575b41a1 feat: add types, base components 2025-06-25 05:08:52 +05:30
Yunus M
0a3fd7a7dc feat: add antlr4, parser files and grammar 2025-06-25 05:08:52 +05:30
56 changed files with 2890 additions and 201 deletions

View File

@@ -169,6 +169,7 @@
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.ant-drawer-close {
padding: 0px;
}

View File

@@ -46,6 +46,7 @@ export enum QueryParams {
msgSystem = 'msgSystem',
destination = 'destination',
kindString = 'kindString',
summaryFilters = 'summaryFilters',
tab = 'tab',
thresholds = 'thresholds',
selectedExplorerView = 'selectedExplorerView',

View File

@@ -4,7 +4,9 @@
overflow-y: hidden;
.full-view-header-container {
height: 40px;
display: flex;
flex-direction: column;
gap: 16px;
}
.graph-container {

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/cognitive-complexity */
import './WidgetFullView.styles.scss';
import {
@@ -8,18 +9,23 @@ import {
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import { ToggleGraphProps } from 'components/Graph/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import Spinner from 'components/Spinner';
import TimePreference from 'components/TimePreferenceDropDown';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import {
timeItems,
timePreferance,
} from 'container/NewWidget/RightContainer/timeItems';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useChartMutable } from 'hooks/useChartMutable';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -52,6 +58,7 @@ function FullView({
onClickHandler,
customOnDragSelect,
setCurrentGraphRef,
enableDrillDown = false,
}: FullViewProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedTime: globalSelectedTime } = useSelector<
@@ -63,6 +70,7 @@ function FullView({
const location = useLocation();
const fullViewRef = useRef<HTMLDivElement>(null);
const { handleRunQuery } = useQueryBuilder();
useEffect(() => {
setCurrentGraphRef(fullViewRef);
@@ -114,6 +122,13 @@ function FullView({
};
});
const { dashboardEditView, handleResetQuery, showResetQuery } = useDrilldown({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
});
useEffect(() => {
setRequestData((prev) => ({
...prev,
@@ -204,71 +219,115 @@ function FullView({
return (
<div className="full-view-container">
<div className="full-view-header-container">
{fullViewOptions && (
<TimeContainer $panelType={widget.panelTypes}>
{response.isFetching && (
<Spin spinning indicator={<LoadingOutlined spin />} />
<OverlayScrollbar>
<>
<div className="full-view-header-container">
{fullViewOptions && (
<TimeContainer $panelType={widget.panelTypes}>
{enableDrillDown && (
<div className="drildown-options-container">
{showResetQuery && (
<Button type="link" onClick={handleResetQuery}>
Reset Query
</Button>
)}
<Button
className="switch-edit-btn"
disabled={response.isFetching || response.isLoading}
onClick={(): void => {
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
}}
>
Switch to Edit Mode
</Button>
</div>
)}
<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
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
/>
<Button
style={{
marginLeft: '4px',
}}
onClick={(): void => {
response.refetch();
}}
type="primary"
icon={<SyncOutlined />}
/>
</TimeContainer>
)}
</div>
{enableDrillDown && (
<>
<QueryBuilderV2
panelType={widget.panelTypes}
version={selectedDashboard?.data?.version || 'v3'}
isListViewPanel={widget.panelTypes === PANEL_TYPES.LIST}
// filterConfigs={filterConfigs}
// queryComponents={queryComponents}
/>
<RightToolbarActions
onStageRunQuery={(): void => {
handleRunQuery(true, true);
}}
/>
</>
)}
</div>
<div
className={cx('graph-container', {
disabled: isDashboardLocked,
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
'list-graph-container': isListView,
})}
ref={fullViewRef}
>
<GraphContainer
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 || '');
<div
className={cx('graph-container', {
disabled: isDashboardLocked,
'height-widget':
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
'list-graph-container': isListView,
})}
ref={fullViewRef}
>
<GraphContainer
style={{
height: isListView ? '100%' : '90%',
}}
/>
)}
<PanelWrapper
queryResponse={response}
widget={widget}
setRequestData={setRequestData}
isFullViewMode
onToggleModelHandler={onToggleModelHandler}
setGraphVisibility={setGraphsVisibilityStates}
graphVisibility={graphsVisibilityStates}
onDragSelect={customOnDragSelect ?? onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
searchTerm={searchTerm}
onClickHandler={onClickHandler}
/>
</GraphContainer>
</div>
isGraphLegendToggleAvailable={canModifyChart}
>
{isTablePanel && (
<Input
addonBefore={<SearchOutlined size={14} />}
className="global-search"
placeholder="Search..."
allowClear
key={widget.id}
onChange={(e): void => {
setSearchTerm(e.target.value || '');
}}
/>
)}
<PanelWrapper
queryResponse={response}
widget={widget}
setRequestData={setRequestData}
isFullViewMode
onToggleModelHandler={onToggleModelHandler}
setGraphVisibility={setGraphsVisibilityStates}
graphVisibility={graphsVisibilityStates}
onDragSelect={customOnDragSelect ?? onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
searchTerm={searchTerm}
onClickHandler={onClickHandler}
enableDrillDown={enableDrillDown}
/>
</GraphContainer>
</div>
</>
</OverlayScrollbar>
</div>
);
}

View File

@@ -18,6 +18,7 @@ export const NotFoundContainer = styled.div`
export const TimeContainer = styled.div<Props>`
display: flex;
justify-content: flex-end;
gap: 16px;
align-items: center;
${({ $panelType }): FlattenSimpleInterpolation =>
$panelType === PANEL_TYPES.TABLE
@@ -25,6 +26,10 @@ export const TimeContainer = styled.div<Props>`
margin-bottom: 1rem;
`
: css``}
.time-container {
display: flex;
}
`;
export const GraphContainer = styled.div<GraphContainerProps>`

View File

@@ -59,6 +59,7 @@ export interface FullViewProps {
isDependedDataLoaded?: boolean;
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
enableDrillDown?: boolean;
}
export interface GraphManagerProps extends UplotProps {

View File

@@ -0,0 +1,84 @@
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 { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
export interface DrilldownQueryProps {
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
enableDrillDown: boolean;
selectedDashboard: Dashboard | undefined;
}
export interface UseDrilldownReturn {
dashboardEditView: string;
handleResetQuery: () => void;
showResetQuery: boolean;
}
const useDrilldown = ({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
}: 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: widget.panelTypes,
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);
}, [redirectWithQueryBuilderData, widget.query]);
return {
dashboardEditView,
handleResetQuery,
showResetQuery,
};
};
export default useDrilldown;

View File

@@ -62,6 +62,7 @@ function WidgetGraphComponent({
customErrorMessage,
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
@@ -226,6 +227,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {
const {
@@ -354,6 +356,7 @@ function WidgetGraphComponent({
onClickHandler={onClickHandler ?? graphClickHandler}
customOnDragSelect={customOnDragSelect}
setCurrentGraphRef={setCurrentGraphRef}
enableDrillDown={enableDrillDown}
/>
</Modal>
@@ -405,6 +408,7 @@ function WidgetGraphComponent({
onOpenTraceBtnClick={onOpenTraceBtnClick}
customSeries={customSeries}
customOnRowClick={customOnRowClick}
enableDrillDown={enableDrillDown}
/>
</div>
)}
@@ -417,6 +421,7 @@ WidgetGraphComponent.defaultProps = {
setLayout: undefined,
onClickHandler: undefined,
customTimeRangeWindowForCoRelation: undefined,
enableDrillDown: false,
};
export default WidgetGraphComponent;

View File

@@ -53,6 +53,7 @@ function GridCardGraph({
customTimeRange,
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -318,6 +319,7 @@ function GridCardGraph({
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
customOnRowClick={customOnRowClick}
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
enableDrillDown={enableDrillDown}
/>
)}
</div>
@@ -333,6 +335,7 @@ GridCardGraph.defaultProps = {
version: 'v3',
analyticsEvent: undefined,
customTimeRangeWindowForCoRelation: undefined,
enableDrillDown: false,
};
export default memo(GridCardGraph);

View File

@@ -41,6 +41,7 @@ export interface WidgetGraphComponentProps {
customErrorMessage?: string;
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
}
export interface GridCardGraphProps {
@@ -69,6 +70,7 @@ export interface GridCardGraphProps {
};
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -53,11 +53,12 @@ import { WidgetRowHeader } from './WidgetRow';
interface GraphLayoutProps {
handle: FullScreenHandle;
enableDrillDown?: boolean;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function GraphLayout(props: GraphLayoutProps): JSX.Element {
const { handle } = props;
const { handle, enableDrillDown = false } = props;
const { safeNavigate } = useSafeNavigate();
const {
selectedDashboard,
@@ -584,6 +585,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
enableDrillDown={enableDrillDown}
/>
</Card>
</CardContainer>
@@ -670,3 +672,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}
export default GraphLayout;
GraphLayout.defaultProps = {
enableDrillDown: false,
};

View File

@@ -4,10 +4,17 @@ import GraphLayoutContainer from './GridCardLayout';
interface GridGraphProps {
handle: FullScreenHandle;
enableDrillDown?: boolean;
}
function GridGraph(props: GridGraphProps): JSX.Element {
const { handle } = props;
return <GraphLayoutContainer handle={handle} />;
const { handle, enableDrillDown = false } = props;
return (
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
);
}
export default GridGraph;
GridGraph.defaultProps = {
enableDrillDown: false,
};

View File

@@ -22,6 +22,7 @@ export type GridTableComponentProps = {
widgetId?: string;
renderColumnCell?: QueryTableProps['renderColumnCell'];
customColTitles?: Record<string, string>;
enableDrillDown?: boolean;
} & Pick<LogsExplorerTableProps, 'data'> &
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;

View File

@@ -1,5 +1,5 @@
/* 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 { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
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 { 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
function evaluateCondition(
operator: string | undefined,
@@ -180,9 +186,9 @@ export function createColumnsAndDataSource(
data: TableData,
currentQuery: Query,
renderColumnCell?: QueryTableProps['renderColumnCell'],
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
const columns: ColumnsType<RowData> =
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
const columns: CustomDataColumnType<RowData>[] =
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
// is the column is the value column then we need to check for the available legend
const legend = item.isValueColumn
? getQueryLegend(currentQuery, item.queryName)
@@ -193,11 +199,13 @@ export function createColumnsAndDataSource(
(query) => query.queryName === item.queryName,
)?.aggregations?.length || 0;
const column: ColumnType<RowData> = {
const column: CustomDataColumnType<RowData> = {
dataIndex: item.id || item.name,
// if no legend present then rely on the column name value
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
width: QUERY_TABLE_CONFIG.width,
isValueColumn: item.isValueColumn,
queryName: item.queryName,
render: renderColumnCell && renderColumnCell[item.name],
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
};

View File

@@ -48,7 +48,6 @@ function LogsExplorerList({
isFilterApplied,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const { activeLogId } = useCopyLogLink();
const {

View File

@@ -11,7 +11,7 @@ function GridGraphs(props: GridGraphsProps): JSX.Element {
const { handle } = props;
return (
<GridComponentSliderContainer>
<GridGraphLayout handle={handle} />
<GridGraphLayout handle={handle} enableDrillDown />
</GridComponentSliderContainer>
);
}

View File

@@ -14,6 +14,7 @@ function WidgetGraphContainer({
setRequestData,
selectedWidget,
isLoadingPanelData,
enableDrillDown = false,
}: WidgetGraphContainerProps): JSX.Element {
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData(
@@ -84,6 +85,7 @@ function WidgetGraphContainer({
queryResponse={queryResponse}
setRequestData={setRequestData}
selectedGraph={selectedGraph}
enableDrillDown={enableDrillDown}
/>
);
}

View File

@@ -36,6 +36,7 @@ function WidgetGraph({
queryResponse,
setRequestData,
selectedGraph,
enableDrillDown = false,
}: WidgetGraphProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const lineChartRef = useRef<ToggleGraphProps>();
@@ -188,6 +189,7 @@ function WidgetGraph({
onClickHandler={graphClickHandler}
graphVisibility={graphVisibility}
setGraphVisibility={setGraphVisibility}
enableDrillDown={enableDrillDown}
/>
</div>
);
@@ -201,6 +203,11 @@ interface WidgetGraphProps {
>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;
}
export default WidgetGraph;
WidgetGraph.defaultProps = {
enableDrillDown: false,
};

View File

@@ -18,6 +18,7 @@ function WidgetGraph({
setRequestData,
selectedWidget,
isLoadingPanelData,
enableDrillDown = false,
}: WidgetGraphContainerProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
@@ -49,6 +50,7 @@ function WidgetGraph({
queryResponse={queryResponse}
setRequestData={setRequestData}
selectedWidget={selectedWidget}
enableDrillDown={enableDrillDown}
/>
</Container>
);

View File

@@ -27,6 +27,7 @@ function LeftContainer({
setRequestData,
isLoadingPanelData,
setQueryResponse,
enableDrillDown = false,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
// const { selectedDashboard } = useDashboard();
@@ -64,6 +65,7 @@ function LeftContainer({
setRequestData={setRequestData}
selectedWidget={selectedWidget}
isLoadingPanelData={isLoadingPanelData}
enableDrillDown={enableDrillDown}
/>
<QueryContainer className="query-section-left-container">
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />

View File

@@ -36,6 +36,11 @@
}
}
.right-header {
display: flex;
gap: 16px;
}
.save-btn {
display: flex;
height: 32px;

View File

@@ -21,6 +21,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
@@ -72,7 +73,10 @@ import {
placeWidgetBetweenRows,
} from './utils';
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
function NewWidget({
selectedGraph,
enableDrillDown = false,
}: NewWidgetProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const {
selectedDashboard,
@@ -690,6 +694,26 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
}
}, [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 queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
};
const updatedSearch = createQueryParams(queryParams);
safeNavigate({
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
search: updatedSearch,
});
}, [query, safeNavigate, dashboardId, currentQuery]);
return (
<Container>
<div className="edit-header">
@@ -706,31 +730,42 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
</Typography.Text>
</Flex>
</div>
{isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
className="save-btn"
>
Save Changes
</Button>
)}
{!isSaveDisabled && (
<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 className="right-header">
{showSwitchToViewModeButton && (
<Button
data-testid="switch-to-view-mode"
disabled={isSaveDisabled || !currentQuery}
onClick={handleSwitchToViewMode}
>
Switch to View Mode
</Button>
)}
{isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
className="save-btn"
>
Save Changes
</Button>
)}
{!isSaveDisabled && (
<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>
<PanelContainer>
@@ -749,6 +784,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
setRequestData={setRequestData}
isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
enableDrillDown={enableDrillDown}
/>
)}
</OverlayScrollbar>

View File

@@ -12,6 +12,7 @@ export interface NewWidgetProps {
selectedGraph: PANEL_TYPES;
yAxisUnit: Widgets['yAxisUnit'];
fillSpans: Widgets['fillSpans'];
enableDrillDown?: boolean;
}
export interface WidgetGraphProps {
@@ -32,6 +33,7 @@ export interface WidgetGraphProps {
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>
>;
enableDrillDown?: boolean;
}
export type WidgetGraphContainerProps = {
@@ -43,4 +45,5 @@ export type WidgetGraphContainerProps = {
selectedGraph: PANEL_TYPES;
selectedWidget: Widgets;
isLoadingPanelData: boolean;
enableDrillDown?: boolean;
};

View File

@@ -2,12 +2,15 @@ import { ToggleGraphProps } from 'components/Graph/types';
import Uplot from 'components/Uplot';
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
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 { useResizeObserver } from 'hooks/useDimensions';
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
import _noop from 'lodash-es/noop';
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { buildHistogramData } from './histogram';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -20,11 +23,58 @@ function HistogramPanelWrapper({
isFullViewMode,
onToggleModelHandler,
onClickHandler,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const isDarkMode = useIsDarkMode();
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,
});
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(
queryResponse.data?.payload.data.result,
@@ -73,7 +123,9 @@ function HistogramPanelWrapper({
setGraphsVisibilityStates: setGraphVisibility,
graphsVisibilityStates: graphVisibility,
mergeAllQueries: widget.mergeAllActiveQueries,
onClickHandler: onClickHandler || _noop,
onClickHandler: enableDrillDown
? clickHandlerWithContextMenu
: onClickHandler ?? _noop,
}),
[
containerDimensions,
@@ -85,6 +137,8 @@ function HistogramPanelWrapper({
widget.id,
widget.mergeAllActiveQueries,
widget.panelTypes,
clickHandlerWithContextMenu,
enableDrillDown,
onClickHandler,
],
);
@@ -92,6 +146,13 @@ function HistogramPanelWrapper({
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<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 && (
<GraphManager
data={histogramData}

View File

@@ -21,6 +21,7 @@ function PanelWrapper({
onOpenTraceBtnClick,
customSeries,
customOnRowClick,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const Component = PanelTypeVsPanelWrapper[
selectedGraph || widget.panelTypes
@@ -49,6 +50,7 @@ function PanelWrapper({
onOpenTraceBtnClick={onOpenTraceBtnClick}
customOnRowClick={customOnRowClick}
customSeries={customSeries}
enableDrillDown={enableDrillDown}
/>
);
}

View File

@@ -6,10 +6,13 @@ import { Pie } from '@visx/shape';
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
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 getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { isNaN } from 'lodash-es';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { useRef, useState } from 'react';
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
@@ -19,6 +22,7 @@ import { lightenColor, tooltipStyles } from './utils';
function PiePanelWrapper({
queryResponse,
widget,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const [active, setActive] = useState<{
label: string;
@@ -48,6 +52,7 @@ function PiePanelWrapper({
label: string;
value: string;
color: string;
record: any;
}[] = [].concat(
...(panelData
.map((d) => {
@@ -55,6 +60,7 @@ function PiePanelWrapper({
return {
label,
value: d?.values?.[0]?.[1],
record: d,
color:
widget?.customLegendColors?.[label] ||
generateColor(
@@ -142,6 +148,26 @@ function PiePanelWrapper({
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,
});
return (
<div className="piechart-wrapper">
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
@@ -165,7 +191,7 @@ function PiePanelWrapper({
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.arcs.map((arc) => {
const { label } = arc.data;
@@ -226,6 +252,17 @@ function PiePanelWrapper({
hideTooltip();
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)} />
@@ -284,6 +321,13 @@ function PiePanelWrapper({
})
}
</Pie>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
{/* Add total value in the center */}
<text

View File

@@ -12,6 +12,7 @@ function TablePanelWrapper({
openTracesButton,
onOpenTraceBtnClick,
customOnRowClick,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const panelData =
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
@@ -31,6 +32,7 @@ function TablePanelWrapper({
widgetId={widget.id}
renderColumnCell={widget.renderColumnCell}
customColTitles={widget.customColTitles}
enableDrillDown={enableDrillDown}
// eslint-disable-next-line react/jsx-props-no-spreading
{...GRID_TABLE_CONFIG}
/>

View File

@@ -6,6 +6,8 @@ import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
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 { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -13,14 +15,16 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
import _noop from 'lodash-es/noop';
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import uPlot from 'uplot';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { PanelWrapperProps } from './panelWrapper.types';
import { getTimeRangeFromUplotAxis } from './utils';
function UplotPanelWrapper({
queryResponse,
@@ -34,6 +38,7 @@ function UplotPanelWrapper({
selectedGraph,
customTooltipElement,
customSeries,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const isDarkMode = useIsDarkMode();
@@ -65,6 +70,25 @@ function UplotPanelWrapper({
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,
});
useEffect(() => {
const {
graphVisibilityStates: localStoredVisibilityState,
@@ -114,6 +138,42 @@ function UplotPanelWrapper({
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,
});
console.log('onClickData: ', data);
// Compute time range if needed and if axes data is available
let timeRange;
if (axesData) {
const { xAxis } = axesData;
timeRange = getTimeRangeFromUplotAxis(xAxis, xValue);
}
if (data && data?.record?.queryName) {
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick],
);
const options = useMemo(
() =>
getUPlotChartOptions({
@@ -123,7 +183,9 @@ function UplotPanelWrapper({
isDarkMode,
onDragSelect,
yAxisUnit: widget?.yAxisUnit,
onClickHandler: onClickHandler || _noop,
onClickHandler: enableDrillDown
? clickHandlerWithContextMenu
: onClickHandler ?? _noop,
thresholds: widget.thresholds,
minTimeScale,
maxTimeScale,
@@ -152,7 +214,7 @@ function UplotPanelWrapper({
containerDimensions,
isDarkMode,
onDragSelect,
onClickHandler,
clickHandlerWithContextMenu,
minTimeScale,
maxTimeScale,
graphVisibility,
@@ -163,6 +225,8 @@ function UplotPanelWrapper({
customTooltipElement,
timezone.value,
customSeries,
enableDrillDown,
onClickHandler,
widget,
],
);
@@ -170,6 +234,13 @@ function UplotPanelWrapper({
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<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 && (
<Alert
message="Selecting multiple legends is currently not supported in case of stacked bar charts"

View File

@@ -266,22 +266,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
demo-app
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
demo-app
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
4.35 s
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
4.35 s
</div>
</div>
</div>
</td>
@@ -292,22 +304,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
customer
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
customer
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
</div>
</div>
</div>
</td>
@@ -318,22 +342,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
mysql
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
mysql
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
</div>
</div>
</div>
</td>
@@ -344,22 +380,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
frontend
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
frontend
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
287 ms
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
287 ms
</div>
</div>
</div>
</td>
@@ -370,22 +418,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
driver
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
driver
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
230 ms
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
230 ms
</div>
</div>
</div>
</td>
@@ -396,22 +456,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
route
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
route
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
66.4 ms
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
66.4 ms
</div>
</div>
</div>
</td>
@@ -422,22 +494,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
redis
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
redis
</div>
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div>
<div
class="line-clamped-wrapper__text"
>
31.3 ms
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
31.3 ms
</div>
</div>
</div>
</td>

View File

@@ -30,6 +30,7 @@ export type PanelWrapperProps = {
onOpenTraceBtnClick?: (record: RowData) => void;
customOnRowClick?: (record: RowData) => void;
customSeries?: (data: QueryData[]) => uPlot.Series[];
enableDrillDown?: boolean;
};
export type TooltipData = {

View File

@@ -71,3 +71,21 @@ export const lightenColor = (color: string, opacity: number): string => {
// Create a new RGBA color string with the specified opacity
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
export const getTimeRangeFromUplotAxis = (
axis: any,
xValue: number,
): { startTime: number; endTime: number } => {
// Use splits if available, otherwise fallback to 10 minutes (600000 milliseconds)
let gap =
(axis as any)._splits && (axis as any)._splits.length > 1
? (axis as any)._splits[1] - (axis as any)._splits[0]
: 600000; // 10 minutes in milliseconds
gap = Math.max(gap, 600000); // Minimum gap of 10 minutes in milliseconds
const startTime = xValue - gap;
const endTime = xValue + gap;
return { startTime, endTime };
};

View File

@@ -0,0 +1,113 @@
import './Breakoutoptions.styles.scss';
import { Input, Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { useGetAggregateKeys } from 'hooks/infraMonitoring/useGetAggregateKeys';
import useDebounce from 'hooks/useDebounce';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useCallback, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { BreakoutOptionsProps } from './contextConfig';
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);
},
[],
);
// TODO: change the api call to get the keys
const { isFetching, data } = useGetAggregateKeys(
{
aggregateAttribute: queryData.aggregateAttribute?.key || '',
dataSource: queryData.dataSource,
aggregateOperator: queryData?.aggregateOperator || '',
searchText: debouncedSearchText,
},
{
queryKey: [
queryData?.aggregateAttribute?.key,
queryData.dataSource,
queryData.aggregateOperator,
debouncedSearchText,
],
enabled: !!queryData,
},
);
const breakoutOptions = useMemo(() => {
const groupByKeys = groupBy.map((item: BaseAutocompleteData) => item.key);
return data?.payload?.attributeKeys?.filter(
(item: BaseAutocompleteData) => !groupByKeys.includes(item.key),
);
}, [data, groupBy]);
console.log('>> queryData', queryData);
console.log('>> groupBy', groupBy);
console.log('>> breakoutOptions', breakoutOptions);
return (
<div>
<section className="search" style={{ padding: '8px 0' }}>
<Input
type="text"
value={searchText}
placeholder="Search breakout options..."
onChange={handleInputChange}
/>
</section>
<div style={{ height: '200px' }}>
<OverlayScrollbar
options={{
overflow: {
x: 'hidden',
},
}}
>
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<>
{isFetching ? (
<OptionsSkeleton />
) : (
breakoutOptions?.map((item: BaseAutocompleteData) => (
<ContextMenu.Item
key={item.key}
onClick={(): void => onColumnClick(item)}
>
{item.key}
</ContextMenu.Item>
))
)}
</>
</OverlayScrollbar>
</div>
</div>
);
}
export default BreakoutOptions;

View File

@@ -0,0 +1,7 @@
.breakout-options-skeleton {
.ant-skeleton-input {
width: 100% !important;
height: 20px !important;
margin: 8px 5px;
}
}

View File

@@ -0,0 +1,150 @@
import { QUERY_BUILDER_OPERATORS_BY_TYPES } from 'constants/queryBuilder';
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import BreakoutOptions from './BreakoutOptions';
import {
getAggregateColumnHeader,
getBaseMeta,
getQueryData,
} from './drilldownUtils';
import { AGGREGATE_OPTIONS, SUPPORTED_OPERATORS } from './menuOptions';
import { getBreakoutQuery } from './tableDrilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
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: BaseAutocompleteData) => void;
}
export function getGroupContextMenuConfig({
query,
clickedData,
panelType,
onColumnClick,
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
const filterKey = clickedData?.column?.dataIndex;
const header = `Filter by ${filterKey}`;
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 === 'table' && clickedData?.column) {
return {
header,
items: 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 {};
}
export function getAggregateContextMenuConfig({
subMenu,
query,
onColumnClick,
aggregateData,
}: {
subMenu?: string;
query: Query;
onColumnClick: (key: string, query?: Query) => void;
aggregateData: AggregateData | null;
}): AggregateContextMenuConfig {
console.log('getAggregateContextMenuConfig', { query, aggregateData });
if (subMenu === 'breakout') {
const queryData = getQueryData(query, aggregateData?.queryName || '');
return {
header: 'Breakout by',
items: (
<BreakoutOptions
queryData={queryData}
onColumnClick={(groupBy: BaseAutocompleteData): void => {
// Use aggregateData.filters
const filtersToAdd = aggregateData?.filters || [];
const breakoutQuery = getBreakoutQuery(
query,
aggregateData,
groupBy,
filtersToAdd,
);
onColumnClick('breakout', breakoutQuery);
}}
/>
),
};
}
// Use aggregateData.queryName
const queryName = aggregateData?.queryName;
const { dataSource, aggregations } = getAggregateColumnHeader(
query,
queryName as string,
);
console.log('dataSource', dataSource);
console.log('aggregations', aggregations);
return {
header: (
<div>
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
<div>{aggregations}</div>
</div>
),
items: AGGREGATE_OPTIONS.map(({ key, label, icon }) => (
<ContextMenu.Item
key={key}
icon={icon}
onClick={(): void => onColumnClick(key)}
>
{label}
</ContextMenu.Item>
)),
};
}

View File

@@ -0,0 +1,335 @@
import { PieArcDatum } from '@visx/shape/lib/shapes/Pie';
import { convertFiltersToExpression } 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 newFilterExpression = convertFiltersToExpression(newFilters);
console.log('BASE META', { filters, newFilters, ...newFilterExpression });
// 4) return a new step object with updated filters
return {
...step,
filters: newFilters,
filter: newFilterExpression,
};
});
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: '' };
}
console.log('queryStep', queryStep);
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 => {
console.log('on Click', {
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
focusedSeries,
});
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>
);
}
console.log('CLICKED DATA: ', record);
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 => {
console.log('arc ->', arc.data);
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,
};
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[] = [];
if (queryName) {
const queryData = getQueryData(query, queryName);
existingFilters = queryData?.filters?.items || [];
}
console.log('existingFilters', { existingFilters, query });
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];
newQuery.builder.queryData[0].filters = {
items: allFilters,
op: 'AND',
};
newQuery.builder.queryData[0].filter = convertFiltersToExpression({
items: allFilters,
op: 'AND',
});
return newQuery;
};

View File

@@ -0,0 +1,99 @@
import { OPERATORS } from 'constants/queryBuilder';
import { 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,
}: {
handleBaseDrilldown: (key: string) => void;
}): {
key: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
}[] => [
{
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 => handleBaseDrilldown('breakout'),
},
];

View File

@@ -0,0 +1,84 @@
import { OPERATORS } 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 { 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) {
console.warn('clickedData is null in getFiltersToAddToView');
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['='],
},
];
}, []) || []
);
};
/**
* Creates a breakout query by adding filters and updating the groupBy
*/
export const getBreakoutQuery = (
query: Query,
aggregateData: AggregateData | null,
groupBy: BaseAutocompleteData,
filtersToAdd: FilterData[],
): Query => {
if (!aggregateData) {
console.warn('aggregateData is null in getBreakoutQuery');
return query;
}
console.log('>> groupBy', groupBy);
console.log('>> aggregateData', aggregateData);
console.log('>> query', query);
const queryWithFilters = addFilterToSelectedQuery(
query,
filtersToAdd,
aggregateData.queryName,
);
const newQuery = cloneDeep(queryWithFilters);
newQuery.builder.queryData = newQuery.builder.queryData.map(
(item: IBuilderQuery) => {
if (item.queryName === aggregateData.queryName) {
return {
...item,
groupBy: [groupBy],
};
}
return item;
},
);
console.log('>> breakoutQuery', newQuery);
return newQuery;
};

View File

@@ -0,0 +1,34 @@
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 BreakoutOptionsProps {
queryData: IBuilderQuery;
onColumnClick: (groupBy: BaseAutocompleteData) => void;
}

View File

@@ -0,0 +1,163 @@
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ContextMenuItem } from './contextConfig';
import { FilterData } 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,
}: {
query: Query;
widgetId: string;
onClose: () => void;
subMenu: string;
setSubMenu: (subMenu: string) => void;
aggregateData: AggregateData | null;
}): {
aggregateDrilldownConfig: {
header?: string | React.ReactNode;
items?: ContextMenuItem;
};
} => {
// const { redirectWithQueryBuilderData } = useQueryBuilder();
// const redirectToViewMode = useCallback(
// (query: Query): void => {
// redirectWithQueryBuilderData(
// query,
// { [QueryParams.expandedWidgetId]: widgetId }, // add only if view mode
// undefined,
// true,
// );
// },
// [widgetId, redirectWithQueryBuilderData],
// );
// const { safeNavigate } = useSafeNavigate();
// const handleAggregateDrilldown = useCallback(
// (key: string, drilldownQuery?: Query): void => {
// console.log('Aggregate drilldown:', { widgetId, query, key, aggregateData });
// if (key === 'breakout') {
// if (!drilldownQuery) {
// setSubMenu(key);
// } else {
// redirectToViewMode(drilldownQuery);
// onClose();
// }
// return;
// }
// const route = getRoute(key);
// const timeRange = aggregateData?.timeRange;
// const filtersToAdd = aggregateData?.filters || [];
// const viewQuery = getViewQuery(
// query,
// 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();
// },
// [
// query,
// widgetId,
// safeNavigate,
// onClose,
// redirectToViewMode,
// setSubMenu,
// aggregateData,
// ],
// );
// const aggregateDrilldownConfig = useMemo(() => {
// if (!aggregateData) {
// console.warn('aggregateData is null in aggregateDrilldownConfig');
// return {};
// }
// return getAggregateContextMenuConfig({
// subMenu,
// query,
// onColumnClick: handleAggregateDrilldown,
// aggregateData,
// });
// }, [handleAggregateDrilldown, query, subMenu, aggregateData]);
// New function to test useBreakout hook
const { breakoutConfig } = useBreakout({
query,
widgetId,
onClose,
aggregateData,
});
const { baseAggregateOptionsConfig } = useBaseAggregateOptions({
query,
widgetId,
onClose,
aggregateData,
subMenu,
setSubMenu,
});
const aggregateDrilldownConfig = useMemo(() => {
if (!aggregateData) {
console.warn('aggregateData is null in testBreakoutConfig');
return {};
}
// If subMenu is breakout, use the new breakout hook
if (subMenu === 'breakout') {
return breakoutConfig;
}
// Otherwise, use the existing getAggregateContextMenuConfig
return baseAggregateOptionsConfig;
}, [subMenu, aggregateData, breakoutConfig, baseAggregateOptionsConfig]);
return { aggregateDrilldownConfig };
};
export default useAggregateDrilldown;

View File

@@ -0,0 +1,166 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useCallback, useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ContextMenuItem } from './contextConfig';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
interface UseBaseAggregateOptionsProps {
query: Query;
widgetId: string;
onClose: () => void;
subMenu: string;
setSubMenu: (subMenu: string) => void;
aggregateData: AggregateData | null;
}
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,
widgetId,
onClose,
subMenu,
setSubMenu,
aggregateData,
}: UseBaseAggregateOptionsProps): {
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
handleBaseDrilldown: (key: string, drilldownQuery?: Query) => void;
} => {
// const { redirectWithQueryBuilderData } = useQueryBuilder();
// const redirectToViewMode = useCallback(
// (query: Query): void => {
// redirectWithQueryBuilderData(
// query,
// { [QueryParams.expandedWidgetId]: widgetId },
// undefined,
// true,
// );
// },
// [widgetId, redirectWithQueryBuilderData],
// );
const { safeNavigate } = useSafeNavigate();
const handleBaseDrilldown = useCallback(
(key: string): void => {
console.log('Base drilldown:', { widgetId, query, key, aggregateData });
if (key === 'breakout') {
// if (!drilldownQuery) {
setSubMenu(key);
return;
// }
}
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
query,
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();
},
[query, widgetId, safeNavigate, onClose, setSubMenu, aggregateData],
);
const baseAggregateOptionsConfig = useMemo(() => {
if (!aggregateData) {
console.warn('aggregateData is null in baseAggregateOptionsConfig');
return {};
}
// Skip breakout logic as it's handled by useBreakout
if (subMenu === 'breakout') {
return {};
}
// Extract the non-breakout logic from getAggregateContextMenuConfig
const { queryName } = aggregateData;
const { dataSource, aggregations } = getAggregateColumnHeader(
query,
queryName as string,
);
console.log('Header', { aggregateData });
return {
header: (
<ContextMenu.Header>
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
<div
style={{
fontWeight: 'normal',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{aggregateData?.label || aggregations}
</div>
</ContextMenu.Header>
),
items: getBaseContextConfig({ handleBaseDrilldown }).map(
({ key, label, icon, onClick }) => (
<ContextMenu.Item key={key} icon={icon} onClick={(): void => onClick()}>
{label}
</ContextMenu.Item>
),
),
};
}, [subMenu, query, handleBaseDrilldown, aggregateData]);
return { baseAggregateOptionsConfig, handleBaseDrilldown };
};
export default useBaseAggregateOptions;

View File

@@ -0,0 +1,92 @@
import { QueryParams } from 'constants/query';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import BreakoutOptions from './BreakoutOptions';
import { getQueryData } from './drilldownUtils';
import { getBreakoutQuery } from './tableDrilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
interface UseBreakoutProps {
query: Query;
widgetId: string;
onClose: () => void;
aggregateData: AggregateData | null;
}
interface BreakoutConfig {
header?: string | React.ReactNode;
items?: React.ReactNode;
}
const useBreakout = ({
query,
widgetId,
onClose,
aggregateData,
}: UseBreakoutProps): {
breakoutConfig: BreakoutConfig;
handleBreakoutClick: (groupBy: BaseAutocompleteData) => void;
} => {
const { redirectWithQueryBuilderData } = useQueryBuilder();
const redirectToViewMode = useCallback(
(query: Query): void => {
redirectWithQueryBuilderData(
query,
{ [QueryParams.expandedWidgetId]: widgetId },
undefined,
true,
);
},
[widgetId, redirectWithQueryBuilderData],
);
const handleBreakoutClick = useCallback(
(groupBy: BaseAutocompleteData): void => {
console.log('Breakout click:', { widgetId, query, groupBy, aggregateData });
if (!aggregateData) {
console.warn('aggregateData is null in handleBreakoutClick');
return;
}
const filtersToAdd = aggregateData.filters || [];
const breakoutQuery = getBreakoutQuery(
query,
aggregateData,
groupBy,
filtersToAdd,
);
redirectToViewMode(breakoutQuery);
onClose();
},
[query, widgetId, aggregateData, redirectToViewMode, onClose],
);
const breakoutConfig = useMemo(() => {
if (!aggregateData) {
console.warn('aggregateData is null in breakoutConfig');
return {};
}
const queryData = getQueryData(query, aggregateData.queryName || '');
return {
header: 'Breakout by',
items: (
<BreakoutOptions
queryData={queryData}
onColumnClick={handleBreakoutClick}
/>
),
};
}, [query, aggregateData, handleBreakoutClick]);
return { breakoutConfig, handleBreakoutClick };
};
export default useBreakout;

View File

@@ -0,0 +1,75 @@
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 } 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;
const filterValue = clickedData?.record?.[filterKey] || '';
const newQuery = addFilterToQuery(query, [
{
filterKey,
filterValue,
operator,
},
]);
redirectToViewMode(newQuery);
onClose();
},
[onClose, clickedData, query, redirectToViewMode],
);
const filterDrilldownConfig = useMemo(() => {
if (!clickedData) {
console.warn('clickedData is null in filterDrilldownConfig');
return {};
}
return getGroupContextMenuConfig({
query,
clickedData,
panelType: 'table',
onColumnClick: handleFilterDrilldown,
});
}, [handleFilterDrilldown, clickedData, query]);
return {
filterDrilldownConfig,
};
};
export default useFilterDrilldown;

View File

@@ -0,0 +1,58 @@
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useMemo } from 'react';
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;
}
export function useGraphContextMenu({
widgetId = '',
query,
graphData,
onClose,
coordinates,
subMenu,
setSubMenu,
}: UseGraphContextMenuProps): {
menuItemsConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
} {
const drilldownQuery = useGetCompositeQueryParam() || query;
const { aggregateDrilldownConfig } = useAggregateDrilldown({
query: drilldownQuery,
widgetId,
onClose,
subMenu,
setSubMenu,
aggregateData: graphData,
});
const menuItemsConfig = useMemo(() => {
if (!coordinates || !graphData) {
return {};
}
// Check if queryName is valid for drilldown
if (!isValidQueryName(graphData.queryName)) {
return {};
}
return aggregateDrilldownConfig;
}, [coordinates, aggregateDrilldownConfig, graphData]);
return { menuItemsConfig };
}
export default useGraphContextMenu;

View File

@@ -0,0 +1,101 @@
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { ClickedData } from 'periscope/components/ContextMenu/types';
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ConfigType } from './contextConfig';
import { isValidQueryName } from './drilldownUtils';
import { getFiltersToAddToView } from './tableDrilldownUtils';
import useAggregateDrilldown 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;
}
export function useTableContextMenu({
widgetId = '',
query,
clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
}: UseTableContextMenuProps): {
menuItemsConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
} {
const drilldownQuery = useGetCompositeQueryParam() || query;
const { filterDrilldownConfig } = useFilterDrilldown({
query: drilldownQuery,
widgetId,
clickedData,
onClose,
});
const aggregateData = useMemo(() => {
if (!clickedData?.column?.isValueColumn) return null;
return {
queryName: String(clickedData.column.queryName || ''),
filters: getFiltersToAddToView(clickedData) || [],
};
}, [clickedData]);
const { aggregateDrilldownConfig } = useAggregateDrilldown({
query: drilldownQuery,
widgetId,
onClose,
subMenu,
setSubMenu,
aggregateData,
});
const menuItemsConfig = useMemo(() => {
if (!coordinates || (!clickedData && !aggregateData)) {
if (!clickedData) {
console.warn('clickedData is null in menuItemsConfig');
}
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;

View File

@@ -21,4 +21,5 @@ export type QueryTableProps = Omit<
sticky?: TableProps<RowData>['sticky'];
searchTerm?: string;
widgetId?: string;
enableDrillDown?: boolean;
};

View File

@@ -13,4 +13,13 @@
width: 0.1rem;
}
}
.clickable-cell {
cursor: pointer;
max-width: fit-content;
&:hover {
color: var(--bg-robin-500);
}
}
}

View File

@@ -1,5 +1,6 @@
import './QueryTable.styles.scss';
import cx from 'classnames';
import { ResizeTable } from 'components/ResizeTable';
import Download from 'container/Download/Download';
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
@@ -7,9 +8,11 @@ import {
createTableColumnsFromQuery,
RowData,
} from 'lib/query/createTableColumnsFromQuery';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import useTableContextMenu from './Drilldown/useTableContextMenu';
import { QueryTableProps } from './QueryTable.intefaces';
import { createDownloadableData } from './utils';
@@ -28,9 +31,31 @@ export function QueryTable({
...props
}: QueryTableProps): JSX.Element {
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
const isQueryTypeBuilder = query.queryType === 'builder';
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { loading } = props;
const { loading, enableDrillDown = false } = props;
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useTableContextMenu({
widgetId: widgetId || '',
query,
clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
});
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
if (columns && dataSource) {
return { columns, dataSource };
@@ -54,6 +79,52 @@ export function QueryTable({
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 = {
pageSize: 10,
showSizeChanger: false,
@@ -82,28 +153,37 @@ export function QueryTable({
}, [newDataSource, onTableSearch, searchTerm]);
return (
<div className="query-table">
{isDownloadEnabled && (
<div className="query-table--download">
<Download
data={downloadableData}
fileName={`${fileName}-${servicename}`}
isLoading={loading as boolean}
/>
</div>
)}
<ResizeTable
columns={tableColumns}
tableLayout="fixed"
dataSource={filterTable === null ? newDataSource : filterTable}
scroll={{ x: 'max-content' }}
pagination={paginationConfig}
widgetId={widgetId}
shouldPersistColumnWidths
sticky={sticky}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
<>
<div className="query-table">
{isDownloadEnabled && (
<div className="query-table--download">
<Download
data={downloadableData}
fileName={`${fileName}-${servicename}`}
isLoading={loading as boolean}
/>
</div>
)}
<ResizeTable
columns={columnsWithClickHandlers}
tableLayout="fixed"
dataSource={filterTable === null ? newDataSource : filterTable}
scroll={{ x: 'max-content' }}
pagination={paginationConfig}
widgetId={widgetId}
shouldPersistColumnWidths
sticky={sticky}
// 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>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
interface NavigateOptions {
replace?: boolean;
state?: any;
newTab?: boolean;
}
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 isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);

View File

@@ -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';
// 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 {
onClick: (
xValue: number,
@@ -13,6 +98,20 @@ export interface OnClickPluginOpts {
queryName: string;
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;
apiResponse?: MetricRangePayloadProps;
}
@@ -24,14 +123,22 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
init: (u: uPlot) => {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
handleClick = function (event: MouseEvent) {
// relative coordinates
const mouseX = event.offsetX + 40;
const mouseY = event.offsetY + 40;
// absolute coordinates
const absoluteMouseX = event.clientX;
const absoluteMouseY = event.clientY;
// Convert pixel positions to data values
// do not use mouseX and mouseY here as it offsets the timestamp as well
const xValue = u.posToVal(event.offsetX, 'x');
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 = {};
const { series } = u;
const apiResult = opts.apiResponse?.data?.result || [];
@@ -46,6 +153,8 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (item?.show && item?._focus) {
console.log('>> outputMetric', apiResult[index - 1]);
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
metric = focusedMetric;
outputMetric.queryName = queryName;
@@ -54,7 +163,57 @@ 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
) {
console.log(
'>> outputMetric',
apiResult[focusedSeriesData.seriesIndex - 1],
);
const { metric: focusedMetric, queryName } =
apiResult[focusedSeriesData.seriesIndex - 1] || [];
metric = focusedMetric;
outputMetric.queryName = queryName;
outputMetric.inFocusOrNot = true;
}
}
const axesData = {
xAxis: u.axes[0],
yAxis: u.axes[1],
};
console.log('>> graph click', {
xValue,
yValue,
mouseX,
mouseY,
metric,
outputMetric,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
});
opts.onClick(
xValue,
yValue,
mouseX,
mouseY,
metric,
outputMetric,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
);
};
u.over.addEventListener('click', handleClick);
},

View File

@@ -58,6 +58,7 @@ function DashboardWidget(): JSX.Element | null {
yAxisUnit={selectedWidget?.yAxisUnit}
selectedGraph={selectedGraph}
fillSpans={selectedWidget?.fillSpans}
enableDrillDown
/>
</PreferenceContextProvider>
);

View File

@@ -198,4 +198,3 @@ export default class FilterQueryListener extends ParseTreeListener {
*/
exitKey?: (ctx: KeyContext) => void;
}

View File

@@ -133,4 +133,3 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
*/
visitKey?: (ctx: KeyContext) => Result;
}

View 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: 210,
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.

View File

@@ -0,0 +1,144 @@
.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;
white-space: normal;
word-break: break-all;
&: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: normal;
word-break: break-all;
overflow-wrap: anywhere;
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: 210px !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;
}

View 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';
}

View File

@@ -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 = 210;
const popoverHeight = 254;
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;