mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-22 00:23:35 +00:00
Compare commits
117 Commits
fix/remove
...
feat/drill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b16e1034 | ||
|
|
749dff2200 | ||
|
|
de05394859 | ||
|
|
a6a9bf5bad | ||
|
|
e767c229aa | ||
|
|
b9cf516201 | ||
|
|
f87e80a0f5 | ||
|
|
f114d0249d | ||
|
|
b4fbd7c673 | ||
|
|
e25d625c4b | ||
|
|
9ca0cc90b0 | ||
|
|
90758dbd32 | ||
|
|
9b559d6251 | ||
|
|
bdfb712395 | ||
|
|
0d2a4b397a | ||
|
|
2c9a51c2ac | ||
|
|
fb43f12a76 | ||
|
|
60e0e84237 | ||
|
|
54d46a1d03 | ||
|
|
73a7246a11 | ||
|
|
163d59bf71 | ||
|
|
fb672eda11 | ||
|
|
43a432b22b | ||
|
|
8107946cb1 | ||
|
|
38ee4aae30 | ||
|
|
001d9ed9fb | ||
|
|
e1abae91a3 | ||
|
|
a9ac3b7e15 | ||
|
|
4a98c54e78 | ||
|
|
9ed4a09caf | ||
|
|
132a31852f | ||
|
|
5686697b6c | ||
|
|
5f4fc12031 | ||
|
|
fe2c42de90 | ||
|
|
d8f2cf1c0e | ||
|
|
a7e8f31561 | ||
|
|
d9d6e7b4f1 | ||
|
|
f8f1a26a43 | ||
|
|
79dfd6f17f | ||
|
|
f386662e00 | ||
|
|
b2de302262 | ||
|
|
6f63076b8e | ||
|
|
8007f954e5 | ||
|
|
b39b24c46f | ||
|
|
70472c587d | ||
|
|
06e89b7199 | ||
|
|
d60ac0d0e1 | ||
|
|
1e4c213df4 | ||
|
|
9bf112cfcf | ||
|
|
a611b8f429 | ||
|
|
872230169c | ||
|
|
4a28954074 | ||
|
|
0df2d9e6da | ||
|
|
67f412477c | ||
|
|
43dc060950 | ||
|
|
a21ae43a1f | ||
|
|
331a8b386f | ||
|
|
ca6c7afa5c | ||
|
|
dc8e5d6df9 | ||
|
|
c68f352aeb | ||
|
|
7863877a49 | ||
|
|
76384c2430 | ||
|
|
4e06d7757b | ||
|
|
5c06429ebe | ||
|
|
aefc7940a7 | ||
|
|
0deae0c73b | ||
|
|
a4c16e5847 | ||
|
|
efb741cf35 | ||
|
|
153f64067c | ||
|
|
c83ae1a485 | ||
|
|
bfd74fb906 | ||
|
|
5d56f05fab | ||
|
|
57ca53c74c | ||
|
|
bde078472b | ||
|
|
6deb75ff46 | ||
|
|
424fd0362d | ||
|
|
1bc51102f6 | ||
|
|
c1b70c05f1 | ||
|
|
8fce0ab1af | ||
|
|
df1923a7c6 | ||
|
|
1e37ae2fd0 | ||
|
|
7b3ea5cc45 | ||
|
|
167ddc6c56 | ||
|
|
dbc1e1fc45 | ||
|
|
01e798f3c1 | ||
|
|
d9010fb3fc | ||
|
|
06363f2e5b | ||
|
|
f1853a6bca | ||
|
|
97e9f5dc8d | ||
|
|
3b959bd2f6 | ||
|
|
9662e43418 | ||
|
|
736bb2ebfb | ||
|
|
879700ea7a | ||
|
|
438ffe45f2 | ||
|
|
723b6b6b79 | ||
|
|
d2df098bb3 | ||
|
|
196ae10f00 | ||
|
|
00eba89e20 | ||
|
|
1739a9e27b | ||
|
|
cfdf714ffa | ||
|
|
49e78b6998 | ||
|
|
762c658c10 | ||
|
|
48e7e33dea | ||
|
|
dc4996c127 | ||
|
|
d95f7b976c | ||
|
|
9a47883064 | ||
|
|
39a90fd33c | ||
|
|
722c3482d2 | ||
|
|
60e84e6681 | ||
|
|
8d1fa84e6a | ||
|
|
6c22197bf4 | ||
|
|
f6c426d0cc | ||
|
|
e21757b2bd | ||
|
|
a87fbabbe7 | ||
|
|
b2847cb05b | ||
|
|
0b575b41a1 | ||
|
|
0a3fd7a7dc |
@@ -169,6 +169,7 @@
|
|||||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-drawer-close {
|
.ant-drawer-close {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export enum QueryParams {
|
|||||||
msgSystem = 'msgSystem',
|
msgSystem = 'msgSystem',
|
||||||
destination = 'destination',
|
destination = 'destination',
|
||||||
kindString = 'kindString',
|
kindString = 'kindString',
|
||||||
|
summaryFilters = 'summaryFilters',
|
||||||
tab = 'tab',
|
tab = 'tab',
|
||||||
thresholds = 'thresholds',
|
thresholds = 'thresholds',
|
||||||
selectedExplorerView = 'selectedExplorerView',
|
selectedExplorerView = 'selectedExplorerView',
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|
||||||
.full-view-header-container {
|
.full-view-header-container {
|
||||||
height: 40px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-container {
|
.graph-container {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import './WidgetFullView.styles.scss';
|
import './WidgetFullView.styles.scss';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -8,18 +9,23 @@ import {
|
|||||||
import { Button, Input, Spin } from 'antd';
|
import { Button, Input, Spin } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { ToggleGraphProps } from 'components/Graph/types';
|
import { ToggleGraphProps } from 'components/Graph/types';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import TimePreference from 'components/TimePreferenceDropDown';
|
import TimePreference from 'components/TimePreferenceDropDown';
|
||||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
|
||||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||||
import {
|
import {
|
||||||
timeItems,
|
timeItems,
|
||||||
timePreferance,
|
timePreferance,
|
||||||
} from 'container/NewWidget/RightContainer/timeItems';
|
} from 'container/NewWidget/RightContainer/timeItems';
|
||||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||||
|
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useChartMutable } from 'hooks/useChartMutable';
|
import { useChartMutable } from 'hooks/useChartMutable';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
@@ -52,6 +58,7 @@ function FullView({
|
|||||||
onClickHandler,
|
onClickHandler,
|
||||||
customOnDragSelect,
|
customOnDragSelect,
|
||||||
setCurrentGraphRef,
|
setCurrentGraphRef,
|
||||||
|
enableDrillDown = false,
|
||||||
}: FullViewProps): JSX.Element {
|
}: FullViewProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const { selectedTime: globalSelectedTime } = useSelector<
|
const { selectedTime: globalSelectedTime } = useSelector<
|
||||||
@@ -63,6 +70,7 @@ function FullView({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { handleRunQuery } = useQueryBuilder();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentGraphRef(fullViewRef);
|
setCurrentGraphRef(fullViewRef);
|
||||||
@@ -114,6 +122,13 @@ function FullView({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { dashboardEditView, handleResetQuery, showResetQuery } = useDrilldown({
|
||||||
|
enableDrillDown,
|
||||||
|
widget,
|
||||||
|
setRequestData,
|
||||||
|
selectedDashboard,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRequestData((prev) => ({
|
setRequestData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -204,71 +219,115 @@ function FullView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="full-view-container">
|
<div className="full-view-container">
|
||||||
<div className="full-view-header-container">
|
<OverlayScrollbar>
|
||||||
{fullViewOptions && (
|
<>
|
||||||
<TimeContainer $panelType={widget.panelTypes}>
|
<div className="full-view-header-container">
|
||||||
{response.isFetching && (
|
{fullViewOptions && (
|
||||||
<Spin spinning indicator={<LoadingOutlined spin />} />
|
<TimeContainer $panelType={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
|
{enableDrillDown && (
|
||||||
selectedTime={selectedTime}
|
<>
|
||||||
setSelectedTime={setSelectedTime}
|
<QueryBuilderV2
|
||||||
/>
|
panelType={widget.panelTypes}
|
||||||
<Button
|
version={selectedDashboard?.data?.version || 'v3'}
|
||||||
style={{
|
isListViewPanel={widget.panelTypes === PANEL_TYPES.LIST}
|
||||||
marginLeft: '4px',
|
// filterConfigs={filterConfigs}
|
||||||
}}
|
// queryComponents={queryComponents}
|
||||||
onClick={(): void => {
|
/>
|
||||||
response.refetch();
|
<RightToolbarActions
|
||||||
}}
|
onStageRunQuery={(): void => {
|
||||||
type="primary"
|
handleRunQuery(true, true);
|
||||||
icon={<SyncOutlined />}
|
}}
|
||||||
/>
|
/>
|
||||||
</TimeContainer>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx('graph-container', {
|
className={cx('graph-container', {
|
||||||
disabled: isDashboardLocked,
|
disabled: isDashboardLocked,
|
||||||
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
'height-widget':
|
||||||
'list-graph-container': isListView,
|
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||||
})}
|
'list-graph-container': isListView,
|
||||||
ref={fullViewRef}
|
})}
|
||||||
>
|
ref={fullViewRef}
|
||||||
<GraphContainer
|
>
|
||||||
style={{
|
<GraphContainer
|
||||||
height: isListView ? '100%' : '90%',
|
style={{
|
||||||
}}
|
height: isListView ? '100%' : '90%',
|
||||||
isGraphLegendToggleAvailable={canModifyChart}
|
|
||||||
>
|
|
||||||
{isTablePanel && (
|
|
||||||
<Input
|
|
||||||
addonBefore={<SearchOutlined size={14} />}
|
|
||||||
className="global-search"
|
|
||||||
placeholder="Search..."
|
|
||||||
allowClear
|
|
||||||
key={widget.id}
|
|
||||||
onChange={(e): void => {
|
|
||||||
setSearchTerm(e.target.value || '');
|
|
||||||
}}
|
}}
|
||||||
/>
|
isGraphLegendToggleAvailable={canModifyChart}
|
||||||
)}
|
>
|
||||||
<PanelWrapper
|
{isTablePanel && (
|
||||||
queryResponse={response}
|
<Input
|
||||||
widget={widget}
|
addonBefore={<SearchOutlined size={14} />}
|
||||||
setRequestData={setRequestData}
|
className="global-search"
|
||||||
isFullViewMode
|
placeholder="Search..."
|
||||||
onToggleModelHandler={onToggleModelHandler}
|
allowClear
|
||||||
setGraphVisibility={setGraphsVisibilityStates}
|
key={widget.id}
|
||||||
graphVisibility={graphsVisibilityStates}
|
onChange={(e): void => {
|
||||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
setSearchTerm(e.target.value || '');
|
||||||
tableProcessedDataRef={tableProcessedDataRef}
|
}}
|
||||||
searchTerm={searchTerm}
|
/>
|
||||||
onClickHandler={onClickHandler}
|
)}
|
||||||
/>
|
<PanelWrapper
|
||||||
</GraphContainer>
|
queryResponse={response}
|
||||||
</div>
|
widget={widget}
|
||||||
|
setRequestData={setRequestData}
|
||||||
|
isFullViewMode
|
||||||
|
onToggleModelHandler={onToggleModelHandler}
|
||||||
|
setGraphVisibility={setGraphsVisibilityStates}
|
||||||
|
graphVisibility={graphsVisibilityStates}
|
||||||
|
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||||
|
tableProcessedDataRef={tableProcessedDataRef}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onClickHandler={onClickHandler}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
|
/>
|
||||||
|
</GraphContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</OverlayScrollbar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const NotFoundContainer = styled.div`
|
|||||||
export const TimeContainer = styled.div<Props>`
|
export const TimeContainer = styled.div<Props>`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
${({ $panelType }): FlattenSimpleInterpolation =>
|
${({ $panelType }): FlattenSimpleInterpolation =>
|
||||||
$panelType === PANEL_TYPES.TABLE
|
$panelType === PANEL_TYPES.TABLE
|
||||||
@@ -25,6 +26,10 @@ export const TimeContainer = styled.div<Props>`
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
`
|
`
|
||||||
: css``}
|
: css``}
|
||||||
|
|
||||||
|
.time-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GraphContainer = styled.div<GraphContainerProps>`
|
export const GraphContainer = styled.div<GraphContainerProps>`
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface FullViewProps {
|
|||||||
isDependedDataLoaded?: boolean;
|
isDependedDataLoaded?: boolean;
|
||||||
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
||||||
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphManagerProps extends UplotProps {
|
export interface GraphManagerProps extends UplotProps {
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
@@ -62,6 +62,7 @@ function WidgetGraphComponent({
|
|||||||
customErrorMessage,
|
customErrorMessage,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
customTimeRangeWindowForCoRelation,
|
customTimeRangeWindowForCoRelation,
|
||||||
|
enableDrillDown,
|
||||||
}: WidgetGraphComponentProps): JSX.Element {
|
}: WidgetGraphComponentProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
const [deleteModal, setDeleteModal] = useState(false);
|
||||||
@@ -226,6 +227,7 @@ function WidgetGraphComponent({
|
|||||||
const onToggleModelHandler = (): void => {
|
const onToggleModelHandler = (): void => {
|
||||||
const existingSearchParams = new URLSearchParams(search);
|
const existingSearchParams = new URLSearchParams(search);
|
||||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||||
|
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||||
if (queryResponse.data?.payload) {
|
if (queryResponse.data?.payload) {
|
||||||
const {
|
const {
|
||||||
@@ -354,6 +356,7 @@ function WidgetGraphComponent({
|
|||||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||||
customOnDragSelect={customOnDragSelect}
|
customOnDragSelect={customOnDragSelect}
|
||||||
setCurrentGraphRef={setCurrentGraphRef}
|
setCurrentGraphRef={setCurrentGraphRef}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -405,6 +408,7 @@ function WidgetGraphComponent({
|
|||||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||||
customSeries={customSeries}
|
customSeries={customSeries}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -417,6 +421,7 @@ WidgetGraphComponent.defaultProps = {
|
|||||||
setLayout: undefined,
|
setLayout: undefined,
|
||||||
onClickHandler: undefined,
|
onClickHandler: undefined,
|
||||||
customTimeRangeWindowForCoRelation: undefined,
|
customTimeRangeWindowForCoRelation: undefined,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WidgetGraphComponent;
|
export default WidgetGraphComponent;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function GridCardGraph({
|
|||||||
customTimeRange,
|
customTimeRange,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
customTimeRangeWindowForCoRelation,
|
customTimeRangeWindowForCoRelation,
|
||||||
|
enableDrillDown,
|
||||||
}: GridCardGraphProps): JSX.Element {
|
}: GridCardGraphProps): JSX.Element {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
@@ -318,6 +319,7 @@ function GridCardGraph({
|
|||||||
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +335,7 @@ GridCardGraph.defaultProps = {
|
|||||||
version: 'v3',
|
version: 'v3',
|
||||||
analyticsEvent: undefined,
|
analyticsEvent: undefined,
|
||||||
customTimeRangeWindowForCoRelation: undefined,
|
customTimeRangeWindowForCoRelation: undefined,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(GridCardGraph);
|
export default memo(GridCardGraph);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface WidgetGraphComponentProps {
|
|||||||
customErrorMessage?: string;
|
customErrorMessage?: string;
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridCardGraphProps {
|
export interface GridCardGraphProps {
|
||||||
@@ -69,6 +70,7 @@ export interface GridCardGraphProps {
|
|||||||
};
|
};
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ import { WidgetRowHeader } from './WidgetRow';
|
|||||||
|
|
||||||
interface GraphLayoutProps {
|
interface GraphLayoutProps {
|
||||||
handle: FullScreenHandle;
|
handle: FullScreenHandle;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||||
const { handle } = props;
|
const { handle, enableDrillDown = false } = props;
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const {
|
const {
|
||||||
selectedDashboard,
|
selectedDashboard,
|
||||||
@@ -584,6 +585,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
version={ENTITY_VERSION_V5}
|
version={ENTITY_VERSION_V5}
|
||||||
onDragSelect={onDragSelect}
|
onDragSelect={onDragSelect}
|
||||||
dataAvailable={checkIfDataExists}
|
dataAvailable={checkIfDataExists}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
@@ -670,3 +672,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default GraphLayout;
|
export default GraphLayout;
|
||||||
|
|
||||||
|
GraphLayout.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import GraphLayoutContainer from './GridCardLayout';
|
|||||||
|
|
||||||
interface GridGraphProps {
|
interface GridGraphProps {
|
||||||
handle: FullScreenHandle;
|
handle: FullScreenHandle;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
function GridGraph(props: GridGraphProps): JSX.Element {
|
function GridGraph(props: GridGraphProps): JSX.Element {
|
||||||
const { handle } = props;
|
const { handle, enableDrillDown = false } = props;
|
||||||
return <GraphLayoutContainer handle={handle} />;
|
return (
|
||||||
|
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GridGraph;
|
export default GridGraph;
|
||||||
|
|
||||||
|
GridGraph.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type GridTableComponentProps = {
|
|||||||
widgetId?: string;
|
widgetId?: string;
|
||||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||||
customColTitles?: Record<string, string>;
|
customColTitles?: Record<string, string>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import { ColumnsType, ColumnType } from 'antd/es/table';
|
import { ColumnType } from 'antd/es/table';
|
||||||
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
||||||
@@ -9,6 +9,12 @@ import { isEmpty, isNaN } from 'lodash-es';
|
|||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { EQueryType } from 'types/common/dashboard';
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
|
||||||
|
// Custom column type that extends ColumnType to include isValueColumn
|
||||||
|
export interface CustomDataColumnType<T> extends ColumnType<T> {
|
||||||
|
isValueColumn?: boolean;
|
||||||
|
queryName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to evaluate the condition based on the operator
|
// Helper function to evaluate the condition based on the operator
|
||||||
function evaluateCondition(
|
function evaluateCondition(
|
||||||
operator: string | undefined,
|
operator: string | undefined,
|
||||||
@@ -180,9 +186,9 @@ export function createColumnsAndDataSource(
|
|||||||
data: TableData,
|
data: TableData,
|
||||||
currentQuery: Query,
|
currentQuery: Query,
|
||||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||||
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
|
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
|
||||||
const columns: ColumnsType<RowData> =
|
const columns: CustomDataColumnType<RowData>[] =
|
||||||
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
|
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
|
||||||
// is the column is the value column then we need to check for the available legend
|
// is the column is the value column then we need to check for the available legend
|
||||||
const legend = item.isValueColumn
|
const legend = item.isValueColumn
|
||||||
? getQueryLegend(currentQuery, item.queryName)
|
? getQueryLegend(currentQuery, item.queryName)
|
||||||
@@ -193,11 +199,13 @@ export function createColumnsAndDataSource(
|
|||||||
(query) => query.queryName === item.queryName,
|
(query) => query.queryName === item.queryName,
|
||||||
)?.aggregations?.length || 0;
|
)?.aggregations?.length || 0;
|
||||||
|
|
||||||
const column: ColumnType<RowData> = {
|
const column: CustomDataColumnType<RowData> = {
|
||||||
dataIndex: item.id || item.name,
|
dataIndex: item.id || item.name,
|
||||||
// if no legend present then rely on the column name value
|
// if no legend present then rely on the column name value
|
||||||
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
||||||
width: QUERY_TABLE_CONFIG.width,
|
width: QUERY_TABLE_CONFIG.width,
|
||||||
|
isValueColumn: item.isValueColumn,
|
||||||
|
queryName: item.queryName,
|
||||||
render: renderColumnCell && renderColumnCell[item.name],
|
render: renderColumnCell && renderColumnCell[item.name],
|
||||||
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ function LogsExplorerList({
|
|||||||
isFilterApplied,
|
isFilterApplied,
|
||||||
}: LogsExplorerListProps): JSX.Element {
|
}: LogsExplorerListProps): JSX.Element {
|
||||||
const ref = useRef<VirtuosoHandle>(null);
|
const ref = useRef<VirtuosoHandle>(null);
|
||||||
|
|
||||||
const { activeLogId } = useCopyLogLink();
|
const { activeLogId } = useCopyLogLink();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function GridGraphs(props: GridGraphsProps): JSX.Element {
|
|||||||
const { handle } = props;
|
const { handle } = props;
|
||||||
return (
|
return (
|
||||||
<GridComponentSliderContainer>
|
<GridComponentSliderContainer>
|
||||||
<GridGraphLayout handle={handle} />
|
<GridGraphLayout handle={handle} enableDrillDown />
|
||||||
</GridComponentSliderContainer>
|
</GridComponentSliderContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function WidgetGraphContainer({
|
|||||||
setRequestData,
|
setRequestData,
|
||||||
selectedWidget,
|
selectedWidget,
|
||||||
isLoadingPanelData,
|
isLoadingPanelData,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphContainerProps): JSX.Element {
|
}: WidgetGraphContainerProps): JSX.Element {
|
||||||
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
|
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
|
||||||
const sortedSeriesData = getSortedSeriesData(
|
const sortedSeriesData = getSortedSeriesData(
|
||||||
@@ -84,6 +85,7 @@ function WidgetGraphContainer({
|
|||||||
queryResponse={queryResponse}
|
queryResponse={queryResponse}
|
||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
selectedGraph={selectedGraph}
|
selectedGraph={selectedGraph}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function WidgetGraph({
|
|||||||
queryResponse,
|
queryResponse,
|
||||||
setRequestData,
|
setRequestData,
|
||||||
selectedGraph,
|
selectedGraph,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphProps): JSX.Element {
|
}: WidgetGraphProps): JSX.Element {
|
||||||
const graphRef = useRef<HTMLDivElement>(null);
|
const graphRef = useRef<HTMLDivElement>(null);
|
||||||
const lineChartRef = useRef<ToggleGraphProps>();
|
const lineChartRef = useRef<ToggleGraphProps>();
|
||||||
@@ -188,6 +189,7 @@ function WidgetGraph({
|
|||||||
onClickHandler={graphClickHandler}
|
onClickHandler={graphClickHandler}
|
||||||
graphVisibility={graphVisibility}
|
graphVisibility={graphVisibility}
|
||||||
setGraphVisibility={setGraphVisibility}
|
setGraphVisibility={setGraphVisibility}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -201,6 +203,11 @@ interface WidgetGraphProps {
|
|||||||
>;
|
>;
|
||||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||||
selectedGraph: PANEL_TYPES;
|
selectedGraph: PANEL_TYPES;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WidgetGraph;
|
export default WidgetGraph;
|
||||||
|
|
||||||
|
WidgetGraph.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function WidgetGraph({
|
|||||||
setRequestData,
|
setRequestData,
|
||||||
selectedWidget,
|
selectedWidget,
|
||||||
isLoadingPanelData,
|
isLoadingPanelData,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphContainerProps): JSX.Element {
|
}: WidgetGraphContainerProps): JSX.Element {
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ function WidgetGraph({
|
|||||||
queryResponse={queryResponse}
|
queryResponse={queryResponse}
|
||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
selectedWidget={selectedWidget}
|
selectedWidget={selectedWidget}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function LeftContainer({
|
|||||||
setRequestData,
|
setRequestData,
|
||||||
isLoadingPanelData,
|
isLoadingPanelData,
|
||||||
setQueryResponse,
|
setQueryResponse,
|
||||||
|
enableDrillDown = false,
|
||||||
}: WidgetGraphProps): JSX.Element {
|
}: WidgetGraphProps): JSX.Element {
|
||||||
const { stagedQuery } = useQueryBuilder();
|
const { stagedQuery } = useQueryBuilder();
|
||||||
// const { selectedDashboard } = useDashboard();
|
// const { selectedDashboard } = useDashboard();
|
||||||
@@ -64,6 +65,7 @@ function LeftContainer({
|
|||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
selectedWidget={selectedWidget}
|
selectedWidget={selectedWidget}
|
||||||
isLoadingPanelData={isLoadingPanelData}
|
isLoadingPanelData={isLoadingPanelData}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
<QueryContainer className="query-section-left-container">
|
<QueryContainer className="query-section-left-container">
|
||||||
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />
|
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />
|
||||||
|
|||||||
@@ -36,6 +36,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
|||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import createQueryParams from 'lib/createQueryParams';
|
||||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||||
@@ -72,7 +73,10 @@ import {
|
|||||||
placeWidgetBetweenRows,
|
placeWidgetBetweenRows,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
function NewWidget({
|
||||||
|
selectedGraph,
|
||||||
|
enableDrillDown = false,
|
||||||
|
}: NewWidgetProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const {
|
const {
|
||||||
selectedDashboard,
|
selectedDashboard,
|
||||||
@@ -690,6 +694,26 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
|
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
|
||||||
|
|
||||||
|
const showSwitchToViewModeButton =
|
||||||
|
enableDrillDown && !isNewDashboard && !!query.get('widgetId');
|
||||||
|
|
||||||
|
const handleSwitchToViewMode = useCallback(() => {
|
||||||
|
if (!query.get('widgetId')) return;
|
||||||
|
const widgetId = query.get('widgetId') || '';
|
||||||
|
const 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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<div className="edit-header">
|
<div className="edit-header">
|
||||||
@@ -706,31 +730,42 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
{isSaveDisabled && (
|
<div className="right-header">
|
||||||
<Button
|
{showSwitchToViewModeButton && (
|
||||||
type="primary"
|
<Button
|
||||||
data-testid="new-widget-save"
|
data-testid="switch-to-view-mode"
|
||||||
loading={updateDashboardMutation.isLoading}
|
disabled={isSaveDisabled || !currentQuery}
|
||||||
disabled={isSaveDisabled}
|
onClick={handleSwitchToViewMode}
|
||||||
onClick={onSaveDashboard}
|
>
|
||||||
className="save-btn"
|
Switch to View Mode
|
||||||
>
|
</Button>
|
||||||
Save Changes
|
)}
|
||||||
</Button>
|
{isSaveDisabled && (
|
||||||
)}
|
<Button
|
||||||
{!isSaveDisabled && (
|
type="primary"
|
||||||
<Button
|
data-testid="new-widget-save"
|
||||||
type="primary"
|
loading={updateDashboardMutation.isLoading}
|
||||||
data-testid="new-widget-save"
|
disabled={isSaveDisabled}
|
||||||
loading={updateDashboardMutation.isLoading}
|
onClick={onSaveDashboard}
|
||||||
disabled={isSaveDisabled}
|
className="save-btn"
|
||||||
onClick={onSaveDashboard}
|
>
|
||||||
icon={<Check size={14} />}
|
Save Changes
|
||||||
className="save-btn"
|
</Button>
|
||||||
>
|
)}
|
||||||
Save Changes
|
{!isSaveDisabled && (
|
||||||
</Button>
|
<Button
|
||||||
)}
|
type="primary"
|
||||||
|
data-testid="new-widget-save"
|
||||||
|
loading={updateDashboardMutation.isLoading}
|
||||||
|
disabled={isSaveDisabled}
|
||||||
|
onClick={onSaveDashboard}
|
||||||
|
icon={<Check size={14} />}
|
||||||
|
className="save-btn"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PanelContainer>
|
<PanelContainer>
|
||||||
@@ -749,6 +784,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
|||||||
setRequestData={setRequestData}
|
setRequestData={setRequestData}
|
||||||
isLoadingPanelData={isLoadingPanelData}
|
isLoadingPanelData={isLoadingPanelData}
|
||||||
setQueryResponse={setQueryResponse}
|
setQueryResponse={setQueryResponse}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</OverlayScrollbar>
|
</OverlayScrollbar>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface NewWidgetProps {
|
|||||||
selectedGraph: PANEL_TYPES;
|
selectedGraph: PANEL_TYPES;
|
||||||
yAxisUnit: Widgets['yAxisUnit'];
|
yAxisUnit: Widgets['yAxisUnit'];
|
||||||
fillSpans: Widgets['fillSpans'];
|
fillSpans: Widgets['fillSpans'];
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetGraphProps {
|
export interface WidgetGraphProps {
|
||||||
@@ -32,6 +33,7 @@ export interface WidgetGraphProps {
|
|||||||
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
|
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WidgetGraphContainerProps = {
|
export type WidgetGraphContainerProps = {
|
||||||
@@ -43,4 +45,5 @@ export type WidgetGraphContainerProps = {
|
|||||||
selectedGraph: PANEL_TYPES;
|
selectedGraph: PANEL_TYPES;
|
||||||
selectedWidget: Widgets;
|
selectedWidget: Widgets;
|
||||||
isLoadingPanelData: boolean;
|
isLoadingPanelData: boolean;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
|||||||
import Uplot from 'components/Uplot';
|
import Uplot from 'components/Uplot';
|
||||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||||
|
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useResizeObserver } from 'hooks/useDimensions';
|
import { useResizeObserver } from 'hooks/useDimensions';
|
||||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||||
import _noop from 'lodash-es/noop';
|
import _noop from 'lodash-es/noop';
|
||||||
|
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { buildHistogramData } from './histogram';
|
import { buildHistogramData } from './histogram';
|
||||||
import { PanelWrapperProps } from './panelWrapper.types';
|
import { PanelWrapperProps } from './panelWrapper.types';
|
||||||
@@ -20,11 +23,58 @@ function HistogramPanelWrapper({
|
|||||||
isFullViewMode,
|
isFullViewMode,
|
||||||
onToggleModelHandler,
|
onToggleModelHandler,
|
||||||
onClickHandler,
|
onClickHandler,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const graphRef = useRef<HTMLDivElement>(null);
|
const graphRef = useRef<HTMLDivElement>(null);
|
||||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
const containerDimensions = useResizeObserver(graphRef);
|
const containerDimensions = useResizeObserver(graphRef);
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget.id || '',
|
||||||
|
query: widget.query,
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clickHandlerWithContextMenu = useCallback(
|
||||||
|
(...args: any[]) => {
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
,
|
||||||
|
focusedSeries,
|
||||||
|
] = args;
|
||||||
|
const data = getUplotClickData({
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
focusedSeries,
|
||||||
|
});
|
||||||
|
if (data && data?.record?.queryName) {
|
||||||
|
onClick(data.coord, { ...data.record, label: data.label });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick],
|
||||||
|
);
|
||||||
|
|
||||||
const histogramData = buildHistogramData(
|
const histogramData = buildHistogramData(
|
||||||
queryResponse.data?.payload.data.result,
|
queryResponse.data?.payload.data.result,
|
||||||
@@ -73,7 +123,9 @@ function HistogramPanelWrapper({
|
|||||||
setGraphsVisibilityStates: setGraphVisibility,
|
setGraphsVisibilityStates: setGraphVisibility,
|
||||||
graphsVisibilityStates: graphVisibility,
|
graphsVisibilityStates: graphVisibility,
|
||||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||||
onClickHandler: onClickHandler || _noop,
|
onClickHandler: enableDrillDown
|
||||||
|
? clickHandlerWithContextMenu
|
||||||
|
: onClickHandler ?? _noop,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
containerDimensions,
|
containerDimensions,
|
||||||
@@ -85,6 +137,8 @@ function HistogramPanelWrapper({
|
|||||||
widget.id,
|
widget.id,
|
||||||
widget.mergeAllActiveQueries,
|
widget.mergeAllActiveQueries,
|
||||||
widget.panelTypes,
|
widget.panelTypes,
|
||||||
|
clickHandlerWithContextMenu,
|
||||||
|
enableDrillDown,
|
||||||
onClickHandler,
|
onClickHandler,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -92,6 +146,13 @@ function HistogramPanelWrapper({
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||||
<GraphManager
|
<GraphManager
|
||||||
data={histogramData}
|
data={histogramData}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function PanelWrapper({
|
|||||||
onOpenTraceBtnClick,
|
onOpenTraceBtnClick,
|
||||||
customSeries,
|
customSeries,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const Component = PanelTypeVsPanelWrapper[
|
const Component = PanelTypeVsPanelWrapper[
|
||||||
selectedGraph || widget.panelTypes
|
selectedGraph || widget.panelTypes
|
||||||
@@ -49,6 +50,7 @@ function PanelWrapper({
|
|||||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
customSeries={customSeries}
|
customSeries={customSeries}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { Pie } from '@visx/shape';
|
|||||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
import { themeColors } from 'constants/theme';
|
import { themeColors } from 'constants/theme';
|
||||||
|
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import getLabelName from 'lib/getLabelName';
|
import getLabelName from 'lib/getLabelName';
|
||||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import { isNaN } from 'lodash-es';
|
import { isNaN } from 'lodash-es';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||||
@@ -19,6 +22,7 @@ import { lightenColor, tooltipStyles } from './utils';
|
|||||||
function PiePanelWrapper({
|
function PiePanelWrapper({
|
||||||
queryResponse,
|
queryResponse,
|
||||||
widget,
|
widget,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const [active, setActive] = useState<{
|
const [active, setActive] = useState<{
|
||||||
label: string;
|
label: string;
|
||||||
@@ -48,6 +52,7 @@ function PiePanelWrapper({
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
record: any;
|
||||||
}[] = [].concat(
|
}[] = [].concat(
|
||||||
...(panelData
|
...(panelData
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
@@ -55,6 +60,7 @@ function PiePanelWrapper({
|
|||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
value: d?.values?.[0]?.[1],
|
value: d?.values?.[0]?.[1],
|
||||||
|
record: d,
|
||||||
color:
|
color:
|
||||||
widget?.customLegendColors?.[label] ||
|
widget?.customLegendColors?.[label] ||
|
||||||
generateColor(
|
generateColor(
|
||||||
@@ -142,6 +148,26 @@ function PiePanelWrapper({
|
|||||||
return active.color === color ? color : lightenedColor;
|
return active.color === color ? color : lightenedColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget.id || '',
|
||||||
|
query: widget.query,
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="piechart-wrapper">
|
<div className="piechart-wrapper">
|
||||||
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
|
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
|
||||||
@@ -165,7 +191,7 @@ function PiePanelWrapper({
|
|||||||
height={size}
|
height={size}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, sonarjs/cognitive-complexity
|
||||||
(pie) =>
|
(pie) =>
|
||||||
pie.arcs.map((arc) => {
|
pie.arcs.map((arc) => {
|
||||||
const { label } = arc.data;
|
const { label } = arc.data;
|
||||||
@@ -226,6 +252,17 @@ function PiePanelWrapper({
|
|||||||
hideTooltip();
|
hideTooltip();
|
||||||
setActive(null);
|
setActive(null);
|
||||||
}}
|
}}
|
||||||
|
onClick={(e): void => {
|
||||||
|
if (enableDrillDown) {
|
||||||
|
const data = getPieChartClickData(arc);
|
||||||
|
if (data && data?.queryName) {
|
||||||
|
onClick(
|
||||||
|
{ x: e.clientX, y: e.clientY },
|
||||||
|
{ ...data, label: data.label },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
|
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
|
||||||
|
|
||||||
@@ -284,6 +321,13 @@ function PiePanelWrapper({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Add total value in the center */}
|
{/* Add total value in the center */}
|
||||||
<text
|
<text
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function TablePanelWrapper({
|
|||||||
openTracesButton,
|
openTracesButton,
|
||||||
onOpenTraceBtnClick,
|
onOpenTraceBtnClick,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const panelData =
|
const panelData =
|
||||||
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
||||||
@@ -31,6 +32,7 @@ function TablePanelWrapper({
|
|||||||
widgetId={widget.id}
|
widgetId={widget.id}
|
||||||
renderColumnCell={widget.renderColumnCell}
|
renderColumnCell={widget.renderColumnCell}
|
||||||
customColTitles={widget.customColTitles}
|
customColTitles={widget.customColTitles}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...GRID_TABLE_CONFIG}
|
{...GRID_TABLE_CONFIG}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Uplot from 'components/Uplot';
|
|||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||||
|
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useResizeObserver } from 'hooks/useDimensions';
|
import { useResizeObserver } from 'hooks/useDimensions';
|
||||||
@@ -13,14 +15,16 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
|||||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||||
import _noop from 'lodash-es/noop';
|
import _noop from 'lodash-es/noop';
|
||||||
|
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useTimezone } from 'providers/Timezone';
|
import { useTimezone } from 'providers/Timezone';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||||
import { getTimeRange } from 'utils/getTimeRange';
|
import { getTimeRange } from 'utils/getTimeRange';
|
||||||
|
|
||||||
import { PanelWrapperProps } from './panelWrapper.types';
|
import { PanelWrapperProps } from './panelWrapper.types';
|
||||||
|
import { getTimeRangeFromUplotAxis } from './utils';
|
||||||
|
|
||||||
function UplotPanelWrapper({
|
function UplotPanelWrapper({
|
||||||
queryResponse,
|
queryResponse,
|
||||||
@@ -34,6 +38,7 @@ function UplotPanelWrapper({
|
|||||||
selectedGraph,
|
selectedGraph,
|
||||||
customTooltipElement,
|
customTooltipElement,
|
||||||
customSeries,
|
customSeries,
|
||||||
|
enableDrillDown = false,
|
||||||
}: PanelWrapperProps): JSX.Element {
|
}: PanelWrapperProps): JSX.Element {
|
||||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
@@ -65,6 +70,25 @@ function UplotPanelWrapper({
|
|||||||
|
|
||||||
const containerDimensions = useResizeObserver(graphRef);
|
const containerDimensions = useResizeObserver(graphRef);
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
clickedData,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
} = useCoordinates();
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget.id || '',
|
||||||
|
query: widget.query,
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {
|
const {
|
||||||
graphVisibilityStates: localStoredVisibilityState,
|
graphVisibilityStates: localStoredVisibilityState,
|
||||||
@@ -114,6 +138,42 @@ function UplotPanelWrapper({
|
|||||||
|
|
||||||
const { timezone } = useTimezone();
|
const { timezone } = useTimezone();
|
||||||
|
|
||||||
|
const clickHandlerWithContextMenu = useCallback(
|
||||||
|
(...args: any[]) => {
|
||||||
|
const [
|
||||||
|
xValue,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
,
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
axesData,
|
||||||
|
focusedSeries,
|
||||||
|
] = args;
|
||||||
|
const data = getUplotClickData({
|
||||||
|
metric,
|
||||||
|
queryData,
|
||||||
|
absoluteMouseX,
|
||||||
|
absoluteMouseY,
|
||||||
|
focusedSeries,
|
||||||
|
});
|
||||||
|
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(
|
const options = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getUPlotChartOptions({
|
getUPlotChartOptions({
|
||||||
@@ -123,7 +183,9 @@ function UplotPanelWrapper({
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
onDragSelect,
|
onDragSelect,
|
||||||
yAxisUnit: widget?.yAxisUnit,
|
yAxisUnit: widget?.yAxisUnit,
|
||||||
onClickHandler: onClickHandler || _noop,
|
onClickHandler: enableDrillDown
|
||||||
|
? clickHandlerWithContextMenu
|
||||||
|
: onClickHandler ?? _noop,
|
||||||
thresholds: widget.thresholds,
|
thresholds: widget.thresholds,
|
||||||
minTimeScale,
|
minTimeScale,
|
||||||
maxTimeScale,
|
maxTimeScale,
|
||||||
@@ -152,7 +214,7 @@ function UplotPanelWrapper({
|
|||||||
containerDimensions,
|
containerDimensions,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
onDragSelect,
|
onDragSelect,
|
||||||
onClickHandler,
|
clickHandlerWithContextMenu,
|
||||||
minTimeScale,
|
minTimeScale,
|
||||||
maxTimeScale,
|
maxTimeScale,
|
||||||
graphVisibility,
|
graphVisibility,
|
||||||
@@ -163,6 +225,8 @@ function UplotPanelWrapper({
|
|||||||
customTooltipElement,
|
customTooltipElement,
|
||||||
timezone.value,
|
timezone.value,
|
||||||
customSeries,
|
customSeries,
|
||||||
|
enableDrillDown,
|
||||||
|
onClickHandler,
|
||||||
widget,
|
widget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -170,6 +234,13 @@ function UplotPanelWrapper({
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
{widget?.stackedBarChart && isFullViewMode && (
|
{widget?.stackedBarChart && isFullViewMode && (
|
||||||
<Alert
|
<Alert
|
||||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||||
|
|||||||
@@ -266,22 +266,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
demo-app
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
demo-app
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
4.35 s
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
4.35 s
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -292,22 +304,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
customer
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
customer
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
431 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
431 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -318,22 +342,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
mysql
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
mysql
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
431 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
431 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -344,22 +380,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
frontend
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
frontend
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
287 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
287 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -370,22 +418,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
driver
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
driver
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
230 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
230 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -396,22 +456,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
route
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
route
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
66.4 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
66.4 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -422,22 +494,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
|||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
redis
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
redis
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="ant-table-cell"
|
class="ant-table-cell"
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<div
|
class=""
|
||||||
class="line-clamped-wrapper__text"
|
role="button"
|
||||||
>
|
tabindex="0"
|
||||||
31.3 ms
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="line-clamped-wrapper__text"
|
||||||
|
>
|
||||||
|
31.3 ms
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type PanelWrapperProps = {
|
|||||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TooltipData = {
|
export type TooltipData = {
|
||||||
|
|||||||
@@ -71,3 +71,21 @@ export const lightenColor = (color: string, opacity: number): string => {
|
|||||||
// Create a new RGBA color string with the specified opacity
|
// Create a new RGBA color string with the specified opacity
|
||||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTimeRangeFromUplotAxis = (
|
||||||
|
axis: any,
|
||||||
|
xValue: number,
|
||||||
|
): { startTime: number; endTime: number } => {
|
||||||
|
// 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 };
|
||||||
|
};
|
||||||
|
|||||||
113
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
113
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal 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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.breakout-options-skeleton {
|
||||||
|
.ant-skeleton-input {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 20px !important;
|
||||||
|
margin: 8px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
frontend/src/container/QueryTable/Drilldown/contextConfig.tsx
Normal file
150
frontend/src/container/QueryTable/Drilldown/contextConfig.tsx
Normal 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>
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
335
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
335
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal 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;
|
||||||
|
};
|
||||||
99
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
99
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal 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'),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
34
frontend/src/container/QueryTable/Drilldown/types.ts
Normal file
34
frontend/src/container/QueryTable/Drilldown/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
92
frontend/src/container/QueryTable/Drilldown/useBreakout.tsx
Normal file
92
frontend/src/container/QueryTable/Drilldown/useBreakout.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -21,4 +21,5 @@ export type QueryTableProps = Omit<
|
|||||||
sticky?: TableProps<RowData>['sticky'];
|
sticky?: TableProps<RowData>['sticky'];
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
widgetId?: string;
|
widgetId?: string;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,4 +13,13 @@
|
|||||||
width: 0.1rem;
|
width: 0.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clickable-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: fit-content;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-robin-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import './QueryTable.styles.scss';
|
import './QueryTable.styles.scss';
|
||||||
|
|
||||||
|
import cx from 'classnames';
|
||||||
import { ResizeTable } from 'components/ResizeTable';
|
import { ResizeTable } from 'components/ResizeTable';
|
||||||
import Download from 'container/Download/Download';
|
import Download from 'container/Download/Download';
|
||||||
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
|
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
|
||||||
@@ -7,9 +8,11 @@ import {
|
|||||||
createTableColumnsFromQuery,
|
createTableColumnsFromQuery,
|
||||||
RowData,
|
RowData,
|
||||||
} from 'lib/query/createTableColumnsFromQuery';
|
} from 'lib/query/createTableColumnsFromQuery';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import useTableContextMenu from './Drilldown/useTableContextMenu';
|
||||||
import { QueryTableProps } from './QueryTable.intefaces';
|
import { QueryTableProps } from './QueryTable.intefaces';
|
||||||
import { createDownloadableData } from './utils';
|
import { createDownloadableData } from './utils';
|
||||||
|
|
||||||
@@ -28,9 +31,31 @@ export function QueryTable({
|
|||||||
...props
|
...props
|
||||||
}: QueryTableProps): JSX.Element {
|
}: QueryTableProps): JSX.Element {
|
||||||
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
|
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
|
||||||
|
const isQueryTypeBuilder = query.queryType === 'builder';
|
||||||
|
|
||||||
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
||||||
const servicename = decodeURIComponent(encodedServiceName);
|
const servicename = decodeURIComponent(encodedServiceName);
|
||||||
const { loading } = props;
|
const { loading, enableDrillDown = false } = 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(() => {
|
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
|
||||||
if (columns && dataSource) {
|
if (columns && dataSource) {
|
||||||
return { columns, dataSource };
|
return { columns, dataSource };
|
||||||
@@ -54,6 +79,52 @@ export function QueryTable({
|
|||||||
|
|
||||||
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns;
|
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns;
|
||||||
|
|
||||||
|
const handleColumnClick = useCallback(
|
||||||
|
(
|
||||||
|
e: React.MouseEvent,
|
||||||
|
record: RowData,
|
||||||
|
column: any,
|
||||||
|
tableColumns: any,
|
||||||
|
): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isQueryTypeBuilder && enableDrillDown) {
|
||||||
|
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isQueryTypeBuilder, enableDrillDown, onClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click handler to columns to capture clicked data
|
||||||
|
const columnsWithClickHandlers = useMemo(
|
||||||
|
() =>
|
||||||
|
tableColumns.map((column: any): any => ({
|
||||||
|
...column,
|
||||||
|
render: (text: any, record: RowData, index: number): JSX.Element => {
|
||||||
|
const originalRender = column.render;
|
||||||
|
const renderedContent = originalRender
|
||||||
|
? originalRender(text, record, index)
|
||||||
|
: text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
className={cx({
|
||||||
|
'clickable-cell': isQueryTypeBuilder && enableDrillDown,
|
||||||
|
})}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e): void => {
|
||||||
|
handleColumnClick(e, record, column, tableColumns);
|
||||||
|
}}
|
||||||
|
onKeyDown={(): void => {}}
|
||||||
|
>
|
||||||
|
{renderedContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[tableColumns, isQueryTypeBuilder, enableDrillDown, handleColumnClick],
|
||||||
|
);
|
||||||
|
|
||||||
const paginationConfig = {
|
const paginationConfig = {
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
showSizeChanger: false,
|
showSizeChanger: false,
|
||||||
@@ -82,28 +153,37 @@ export function QueryTable({
|
|||||||
}, [newDataSource, onTableSearch, searchTerm]);
|
}, [newDataSource, onTableSearch, searchTerm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="query-table">
|
<>
|
||||||
{isDownloadEnabled && (
|
<div className="query-table">
|
||||||
<div className="query-table--download">
|
{isDownloadEnabled && (
|
||||||
<Download
|
<div className="query-table--download">
|
||||||
data={downloadableData}
|
<Download
|
||||||
fileName={`${fileName}-${servicename}`}
|
data={downloadableData}
|
||||||
isLoading={loading as boolean}
|
fileName={`${fileName}-${servicename}`}
|
||||||
/>
|
isLoading={loading as boolean}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
<ResizeTable
|
)}
|
||||||
columns={tableColumns}
|
<ResizeTable
|
||||||
tableLayout="fixed"
|
columns={columnsWithClickHandlers}
|
||||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
tableLayout="fixed"
|
||||||
scroll={{ x: 'max-content' }}
|
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||||
pagination={paginationConfig}
|
scroll={{ x: 'max-content' }}
|
||||||
widgetId={widgetId}
|
pagination={paginationConfig}
|
||||||
shouldPersistColumnWidths
|
widgetId={widgetId}
|
||||||
sticky={sticky}
|
shouldPersistColumnWidths
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
sticky={sticky}
|
||||||
{...props}
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
|||||||
interface NavigateOptions {
|
interface NavigateOptions {
|
||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
state?: any;
|
state?: any;
|
||||||
|
newTab?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SafeNavigateParams {
|
interface SafeNavigateParams {
|
||||||
@@ -113,6 +114,16 @@ export const useSafeNavigate = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If newTab is true, open in new tab and return early
|
||||||
|
if (options?.newTab) {
|
||||||
|
const targetPath =
|
||||||
|
typeof to === 'string'
|
||||||
|
? to
|
||||||
|
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||||
|
window.open(targetPath, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
|
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
|
||||||
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
|
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,90 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import { themeColors } from 'constants/theme';
|
||||||
|
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
|
|
||||||
|
// Helper function to get the focused/highlighted series at a specific position
|
||||||
|
export const getFocusedSeriesAtPosition = (
|
||||||
|
e: MouseEvent,
|
||||||
|
u: uPlot,
|
||||||
|
): {
|
||||||
|
seriesIndex: number;
|
||||||
|
seriesName: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
show: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
|
} | null => {
|
||||||
|
const bbox = u.over.getBoundingClientRect();
|
||||||
|
const left = e.clientX - bbox.left;
|
||||||
|
const top = e.clientY - bbox.top;
|
||||||
|
|
||||||
|
const timestampIndex = u.posToIdx(left);
|
||||||
|
let focusedSeriesIndex = -1;
|
||||||
|
let closestPixelDiff = Infinity;
|
||||||
|
|
||||||
|
// Check all series (skip index 0 which is the x-axis)
|
||||||
|
for (let i = 1; i < u.data.length; i++) {
|
||||||
|
const series = u.data[i];
|
||||||
|
const seriesValue = series[timestampIndex];
|
||||||
|
|
||||||
|
if (
|
||||||
|
seriesValue !== undefined &&
|
||||||
|
seriesValue !== null &&
|
||||||
|
!Number.isNaN(seriesValue)
|
||||||
|
) {
|
||||||
|
const seriesYPx = u.valToPos(seriesValue, 'y');
|
||||||
|
const pixelDiff = Math.abs(seriesYPx - top);
|
||||||
|
|
||||||
|
if (pixelDiff < closestPixelDiff) {
|
||||||
|
closestPixelDiff = pixelDiff;
|
||||||
|
focusedSeriesIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found a focused series, return its data
|
||||||
|
if (focusedSeriesIndex > 0) {
|
||||||
|
const series = u.series[focusedSeriesIndex];
|
||||||
|
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
|
||||||
|
|
||||||
|
// Ensure we have a valid value
|
||||||
|
if (
|
||||||
|
seriesValue !== undefined &&
|
||||||
|
seriesValue !== null &&
|
||||||
|
!Number.isNaN(seriesValue)
|
||||||
|
) {
|
||||||
|
// Get color - try series stroke first, then generate based on label
|
||||||
|
let color = '#000000';
|
||||||
|
if (typeof series.stroke === 'string') {
|
||||||
|
color = series.stroke;
|
||||||
|
} else if (typeof series.fill === 'string') {
|
||||||
|
color = series.fill;
|
||||||
|
} else {
|
||||||
|
// Generate color based on series label (like the tooltip plugin does)
|
||||||
|
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
|
||||||
|
// Detect theme mode by checking body class
|
||||||
|
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||||
|
color = generateColor(
|
||||||
|
seriesLabel,
|
||||||
|
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seriesIndex: focusedSeriesIndex,
|
||||||
|
seriesName: series.label || `Series ${focusedSeriesIndex}`,
|
||||||
|
value: seriesValue as number,
|
||||||
|
color,
|
||||||
|
show: series.show !== false,
|
||||||
|
isFocused: true, // This indicates it's the highlighted/bold one
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface OnClickPluginOpts {
|
export interface OnClickPluginOpts {
|
||||||
onClick: (
|
onClick: (
|
||||||
xValue: number,
|
xValue: number,
|
||||||
@@ -13,6 +98,20 @@ export interface OnClickPluginOpts {
|
|||||||
queryName: string;
|
queryName: string;
|
||||||
inFocusOrNot: boolean;
|
inFocusOrNot: boolean;
|
||||||
},
|
},
|
||||||
|
absoluteMouseX?: number,
|
||||||
|
absoluteMouseY?: number,
|
||||||
|
axesData?: {
|
||||||
|
xAxis: any;
|
||||||
|
yAxis: any;
|
||||||
|
},
|
||||||
|
focusedSeries?: {
|
||||||
|
seriesIndex: number;
|
||||||
|
seriesName: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
show: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
|
} | null,
|
||||||
) => void;
|
) => void;
|
||||||
apiResponse?: MetricRangePayloadProps;
|
apiResponse?: MetricRangePayloadProps;
|
||||||
}
|
}
|
||||||
@@ -24,14 +123,22 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
|||||||
init: (u: uPlot) => {
|
init: (u: uPlot) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||||
handleClick = function (event: MouseEvent) {
|
handleClick = function (event: MouseEvent) {
|
||||||
|
// relative coordinates
|
||||||
const mouseX = event.offsetX + 40;
|
const mouseX = event.offsetX + 40;
|
||||||
const mouseY = event.offsetY + 40;
|
const mouseY = event.offsetY + 40;
|
||||||
|
|
||||||
|
// absolute coordinates
|
||||||
|
const absoluteMouseX = event.clientX;
|
||||||
|
const absoluteMouseY = event.clientY;
|
||||||
|
|
||||||
// Convert pixel positions to data values
|
// Convert pixel positions to data values
|
||||||
// do not use mouseX and mouseY here as it offsets the timestamp as well
|
// do not use mouseX and mouseY here as it offsets the timestamp as well
|
||||||
const xValue = u.posToVal(event.offsetX, 'x');
|
const xValue = u.posToVal(event.offsetX, 'x');
|
||||||
const yValue = u.posToVal(event.offsetY, 'y');
|
const yValue = u.posToVal(event.offsetY, 'y');
|
||||||
|
|
||||||
|
// Get the focused/highlighted series (the one that would be bold in hover)
|
||||||
|
const focusedSeries = getFocusedSeriesAtPosition(event, u);
|
||||||
|
|
||||||
let metric = {};
|
let metric = {};
|
||||||
const { series } = u;
|
const { series } = u;
|
||||||
const apiResult = opts.apiResponse?.data?.result || [];
|
const apiResult = opts.apiResponse?.data?.result || [];
|
||||||
@@ -46,6 +153,8 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (item?.show && item?._focus) {
|
if (item?.show && item?._focus) {
|
||||||
|
console.log('>> outputMetric', apiResult[index - 1]);
|
||||||
|
|
||||||
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
|
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
|
||||||
metric = focusedMetric;
|
metric = focusedMetric;
|
||||||
outputMetric.queryName = queryName;
|
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);
|
u.over.addEventListener('click', handleClick);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function DashboardWidget(): JSX.Element | null {
|
|||||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||||
selectedGraph={selectedGraph}
|
selectedGraph={selectedGraph}
|
||||||
fillSpans={selectedWidget?.fillSpans}
|
fillSpans={selectedWidget?.fillSpans}
|
||||||
|
enableDrillDown
|
||||||
/>
|
/>
|
||||||
</PreferenceContextProvider>
|
</PreferenceContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,4 +198,3 @@ export default class FilterQueryListener extends ParseTreeListener {
|
|||||||
*/
|
*/
|
||||||
exitKey?: (ctx: KeyContext) => void;
|
exitKey?: (ctx: KeyContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,4 +133,3 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
|
|||||||
*/
|
*/
|
||||||
visitKey?: (ctx: KeyContext) => Result;
|
visitKey?: (ctx: KeyContext) => Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
160
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
160
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import './styles.scss';
|
||||||
|
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { Coordinates, PopoverPosition } from './types';
|
||||||
|
import { useCoordinates } from './useCoordinates';
|
||||||
|
|
||||||
|
export { useCoordinates };
|
||||||
|
export type { ClickedData, Coordinates, PopoverPosition } from './types';
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
coordinates: Coordinates | null;
|
||||||
|
popoverPosition?: PopoverPosition | null;
|
||||||
|
title?: string;
|
||||||
|
items?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuItemProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
danger = false,
|
||||||
|
}: ContextMenuItemProps): JSX.Element {
|
||||||
|
const className = `context-menu-item${disabled ? ' disabled' : ''}${
|
||||||
|
danger ? ' danger' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={className}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{icon && <span className="icon">{icon}</span>}
|
||||||
|
<span className="text">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuHeaderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuHeader({ children }: ContextMenuHeaderProps): JSX.Element {
|
||||||
|
return <div className="context-menu-header">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: ContextMenuProps): JSX.Element | null {
|
||||||
|
if (!coordinates || !items) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position: PopoverPosition = popoverPosition ?? {
|
||||||
|
left: coordinates.x + 10,
|
||||||
|
top: coordinates.y - 10,
|
||||||
|
placement: 'right',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render backdrop using portal to ensure it covers the entire viewport
|
||||||
|
const backdrop = createPortal(
|
||||||
|
<div
|
||||||
|
className="context-menu-backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e): void => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Close context menu"
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{backdrop}
|
||||||
|
<Popover
|
||||||
|
content={items}
|
||||||
|
title={title}
|
||||||
|
open={Boolean(coordinates)}
|
||||||
|
onOpenChange={(open: boolean): void => {
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
trigger="click"
|
||||||
|
overlayStyle={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.left,
|
||||||
|
top: position.top,
|
||||||
|
width: 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.
|
||||||
144
frontend/src/periscope/components/ContextMenu/styles.scss
Normal file
144
frontend/src/periscope/components/ContextMenu/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
31
frontend/src/periscope/components/ContextMenu/types.ts
Normal file
31
frontend/src/periscope/components/ContextMenu/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { CustomDataColumnType } from 'container/GridTableComponent/utils';
|
||||||
|
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||||
|
|
||||||
|
export interface ClickedData {
|
||||||
|
record: RowData;
|
||||||
|
column: CustomDataColumnType<RowData>;
|
||||||
|
tableColumns?: CustomDataColumnType<RowData>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coordinates {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopoverPosition {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
placement:
|
||||||
|
| 'top'
|
||||||
|
| 'topLeft'
|
||||||
|
| 'topRight'
|
||||||
|
| 'bottom'
|
||||||
|
| 'bottomLeft'
|
||||||
|
| 'bottomRight'
|
||||||
|
| 'left'
|
||||||
|
| 'leftTop'
|
||||||
|
| 'leftBottom'
|
||||||
|
| 'right'
|
||||||
|
| 'rightTop'
|
||||||
|
| 'rightBottom';
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { Coordinates, PopoverPosition } from './types';
|
||||||
|
|
||||||
|
// Custom hook for managing coordinates
|
||||||
|
export const useCoordinates = (): {
|
||||||
|
coordinates: Coordinates | null;
|
||||||
|
clickedData: any;
|
||||||
|
popoverPosition: PopoverPosition | null;
|
||||||
|
onClick: (coordinates: { x: number; y: number }, data?: any) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
subMenu: string; // todo: create enum
|
||||||
|
setSubMenu: (subMenu: string) => void;
|
||||||
|
} => {
|
||||||
|
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
|
||||||
|
const [clickedData, setClickedData] = useState<any>(null);
|
||||||
|
const [subMenu, setSubMenu] = useState<string>('');
|
||||||
|
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculatePosition = useCallback(
|
||||||
|
(x: number, y: number): PopoverPosition => {
|
||||||
|
const windowWidth = window.innerWidth;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const popoverWidth = 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;
|
||||||
Reference in New Issue
Block a user