Compare commits

...

81 Commits

Author SHA1 Message Date
Piyush Singariya
19f4cde52e fix: package test fixed 2026-03-17 13:46:58 +05:30
srikanthccv
fcce8b4008 chore: text search tests 2026-03-17 09:08:02 +05:30
srikanthccv
c2c2678125 chore: keep message contained to field mapper 2026-03-17 08:55:37 +05:30
srikanthccv
73dc095b79 Merge branch 'main' into keep-message-contained 2026-03-17 05:11:24 +05:30
Piyush Singariya
b2adbb510b fix: remove the exception checking 2026-03-16 19:46:13 +05:30
Piyush Singariya
4f7ec4c057 revert: remove unused JSON Field datatype 2026-03-16 16:24:38 +05:30
Piyush Singariya
9c5a5488e2 fix: fallback expr switch case 2026-03-16 16:22:14 +05:30
Ashwin Bhatkal
09b6382820 chore: separate dashboard slider from dashboard provider + refactor (#10572)
* chore: separate dashboard slider from dashboard provider + refactor

* chore: resolve self comments
2026-03-16 08:12:09 +00:00
Ashwin Bhatkal
9689b847f0 chore: add slack notification on dequeue from merge queue (#10580)
* chore: add slack notification on merge queue failure

* chore: break type

* chore: update yaml

* chore: update yaml

* chore: update yaml

* chore: update yaml

* chore: update yaml

* chore: update yaml

* chore: update yaml

* chore: resolve comments
2026-03-16 07:12:19 +00:00
Vishal Sharma
15e5938e95 fix: add allInOneLightMode SVG for light mode (#10589) 2026-03-16 06:59:28 +00:00
Abhi kumar
c5ef455283 fix: added fix for panel setting scrollbar issue (#10587)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* fix: added fix for panel setting scrollbar issue

* fix: added changes for panel switch
2026-03-13 19:30:49 +00:00
Piyush Singariya
9ee23eae06 Merge branch 'main' into merge-json-col-fields 2026-03-13 23:43:27 +05:30
Ishan
2316b5be83 Sig 3634 revert (#10578)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* Revert "Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)"

This reverts commit 5b8d5fbfd3.

* fix: stop bubble
2026-03-13 15:29:28 +00:00
Piyush Singariya
0f36479787 Merge branch 'main' into merge-json-col-fields 2026-03-13 19:55:05 +05:30
Piyush Singariya
95a9a24875 fix: message field key search in JSON Logs (#10577)
* feat: work in progress

* fix: test run success

* fix: in progress

* fix: excluding message from metadata fetch

* test: cleared

* fix: key name in metadata

* fix: uncomment tests

* chore: change to method for staticfields

* fix: remove confusing comments; remove usage of logical keyword

* chore: shift method above business logic

* chore: changes based on review

* fix: comments in metadata_store.go
2026-03-13 19:16:05 +05:30
Abhi kumar
937ebc1582 feat: added section in panel settings (#10569)
* feat: added section in panel settings

* chore: minor changes

* fix: fixed failing tests

* fix: minor style fixes

* chore: updated the categorisation

* chore: updated styles

* chore: minor styles improvements

* chore: formatting unit section fix
2026-03-13 13:22:10 +00:00
Ashwin Bhatkal
dcc8173c79 fix: variables initial url state (#10579)
* fix: variables-initial-url-state

* chore: add tests
2026-03-13 11:16:47 +00:00
Ashwin Bhatkal
4b4ef5ce58 fix: edit mode variables not persisting value (#10576)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: edit mode variables not persisting value

* chore: move into hook

* chore: add tests

* chore: fix tests

* chore: move functions
2026-03-13 07:49:40 +00:00
Piyush Singariya
7d1e39037c chore: minor changes based on review 2026-03-13 13:06:56 +05:30
Piyush Singariya
54f104db5f fix: cursor comments 2026-03-13 12:15:12 +05:30
Yunus M
5b8d5fbfd3 Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This reverts commit 557451ed81.
2026-03-12 19:24:49 +00:00
Piyush Singariya
ab0852bbfb feat: update message as typehint in JSON Column (#10545) 2026-03-11 14:50:59 +05:30
Piyush Singariya
4c7aba680e fix: lint error 2026-03-10 14:00:12 +05:30
Piyush Singariya
23c247a1ba Merge branch 'main' into merge-json-col-fields 2026-03-10 13:28:30 +05:30
Piyush Singariya
4777b13ddf fix: shift warning attachment to getKeySelectors 2026-03-03 13:27:23 +05:30
Piyush Singariya
2d3060bac4 chore: remove unnecessary change 2026-02-25 12:09:30 +05:30
Piyush Singariya
9101d51920 Merge branch 'main' into merge-json-col-fields 2026-02-23 12:50:11 +05:30
Piyush Singariya
82b82b0208 chore: addressing comments from Nitya 2026-02-23 12:49:48 +05:30
Nityananda Gohain
51bd760d9a Merge branch 'main' into merge-json-col-fields 2026-02-20 15:53:31 +05:30
Piyush Singariya
2a492cc783 fix: change warning to a const to fix tests 2026-02-20 14:54:16 +05:30
Piyush Singariya
24afdad36c fix: append warnings from fieldkeys 2026-02-20 14:51:13 +05:30
Piyush Singariya
5d20019207 fix: body.message not being mapped correctly 2026-02-20 14:26:12 +05:30
Piyush Singariya
1963d5811d Merge branch 'main' into merge-json-col-fields 2026-02-20 10:08:25 +05:30
Piyush Singariya
15cfccad74 revert: change ReadMultiple is needed 2026-02-20 10:08:11 +05:30
Piyush Singariya
a0399560e3 revert: remvoing unused function 2026-02-19 16:33:12 +05:30
Piyush Singariya
265e337d5c Merge branch 'main' into merge-json-col-fields 2026-02-19 16:29:55 +05:30
Piyush Singariya
bb8c874755 fix: go lint 2026-02-17 17:19:24 +05:30
Piyush Singariya
13cbe03d64 fix: tests 2026-02-17 16:58:00 +05:30
Piyush Singariya
93621c29b7 fix: go mod changes 2026-02-17 16:29:00 +05:30
Piyush Singariya
2c691b5a75 fix: test fixed 2026-02-17 16:28:54 +05:30
Piyush Singariya
cd7e1bb114 Merge branch 'main' into merge-json-col-fields 2026-02-17 16:22:58 +05:30
Piyush Singariya
a1d2ec8b8a fix: remove unused function 2026-02-17 16:18:38 +05:30
Piyush Singariya
8bbafb52d5 fix: go.mod required changes 2026-02-17 16:16:17 +05:30
Piyush Singariya
075cfab463 feat: mapping body_v2.message:string map to body 2026-02-17 13:26:34 +05:30
Piyush Singariya
86bccaac0c test: blocked on pr #10153 2026-02-16 15:24:31 +05:30
Piyush Singariya
de1aac63c0 revert: more unrelated change 2026-02-16 13:19:49 +05:30
Piyush Singariya
14fe8745b5 Merge branch 'main' into merge-json-col-fields 2026-02-16 13:14:39 +05:30
Piyush Singariya
4013c7ee03 revert: few unrelated changes 2026-02-16 13:13:07 +05:30
Piyush Singariya
0d34360e0b fix: handle datatype collision 2026-01-30 12:17:28 +05:30
srikanthccv
d204c89dec Merge branch 'main' into merge-json-col-fields 2026-01-30 02:12:14 +05:30
Piyush Singariya
8dd33c1ab7 Merge branch 'main' into merge-json-col-fields 2026-01-29 19:57:22 +05:30
Piyush Singariya
8e5c3d5ae1 chore: merge json fields 2026-01-29 16:46:00 +05:30
Piyush Singariya
d45bb52f33 Merge branch 'has-jsonqb' into merge-json-col-fields 2026-01-29 13:21:13 +05:30
Piyush Singariya
e71818292d fix: go test flakiness 2026-01-29 10:17:53 +05:30
Piyush Singariya
37557f7f24 Merge branch 'main' into has-jsonqb 2026-01-29 09:12:22 +05:30
Piyush Singariya
27ff102660 Merge branch 'main' into has-jsonqb 2026-01-28 17:44:48 +05:30
Piyush Singariya
cb2aa4cffd fix: tests 2026-01-28 17:42:17 +05:30
Piyush Singariya
58d1d84ec7 test: fix 2026-01-28 15:34:22 +05:30
Piyush Singariya
d8e116a7bc fix: merge conflict 2026-01-28 15:15:50 +05:30
Piyush Singariya
6a48bdc37e Merge branch 'main' into has-jsonqb 2026-01-28 15:15:17 +05:30
Piyush Singariya
ffb62432f8 chore: var renamed 2026-01-28 14:42:51 +05:30
Piyush Singariya
57c51f070c fix: merge json body columns together 2026-01-28 14:36:15 +05:30
Piyush Singariya
36becfc7a2 fix: removed comment 2026-01-27 13:20:52 +05:30
Piyush Singariya
8e71de09f3 fix: remove unnecessary bool checking 2026-01-27 13:16:30 +05:30
Piyush Singariya
56de92de73 fix: changes based on review from Srikanth 2026-01-27 13:12:32 +05:30
Piyush Singariya
62b10f8e77 Merge branch 'main' into has-jsonqb 2026-01-27 10:04:32 +05:30
Piyush Singariya
20b53d7856 fix: review based on tushar 2026-01-27 10:04:15 +05:30
Piyush Singariya
8f2c506304 fix: json qb test fix 2026-01-22 22:00:06 +05:30
Srikanth Chekuri
7b5b9027dd Merge branch 'main' into has-jsonqb 2026-01-22 20:19:39 +05:30
Piyush Singariya
b77f97fcb7 fix: tests 2026-01-22 17:24:26 +05:30
Piyush Singariya
62942a4162 fix: tests 2026-01-22 15:47:20 +05:30
Piyush Singariya
349bbbbf1d Merge branch 'main' into has-jsonqb 2026-01-22 12:36:45 +05:30
Piyush Singariya
1966a7a5f6 fix: empty filteredArrays condition 2026-01-22 12:36:29 +05:30
Piyush Singariya
a4eed9ff13 fix: build json plans in metadata 2026-01-22 12:33:51 +05:30
Piyush Singariya
24d1ee33b5 revert: gitignore change 2026-01-22 10:39:02 +05:30
Srikanth Chekuri
3402203021 Merge branch 'main' into has-jsonqb 2026-01-21 13:47:39 +05:30
Piyush Singariya
e8e4897cc8 fix: tests GroupBy 2026-01-20 12:10:44 +05:30
Piyush Singariya
96fb88aaee fix: ignored .vscode in gitignore 2026-01-20 11:47:34 +05:30
Piyush Singariya
5a00e6c2cd Merge branch 'main' into has-jsonqb 2026-01-20 11:44:32 +05:30
Piyush Singariya
e2500cff7d fix: tests expected queries and values 2026-01-20 11:40:56 +05:30
Piyush Singariya
4864c3bc37 feat: has JSON QB 2026-01-20 11:23:50 +05:30
63 changed files with 4083 additions and 1122 deletions

5
.github/CODEOWNERS vendored
View File

@@ -127,12 +127,15 @@
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend /frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend /frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Dashboard Page ## Dashboard Page
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend /frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend /frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend /frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Public Dashboard Page ## Public Dashboard Page

59
.github/workflows/mergequeueci.yaml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: mergequeueci
on:
pull_request:
types:
- dequeued
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: alert
uses: slackapi/slack-github-action@v2.1.1
with:
webhook: ${{ secrets.SLACK_MERGE_QUEUE_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"text": ":x: PR removed from merge queue",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":x: PR Removed from Merge Queue"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*<${{ github.event.pull_request.html_url }}|PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}>*"
}
},
{
"type": "divider"
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Author*\n@${{ github.event.pull_request.user.login }}"
}
]
}
]
}
- name: comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments \
-f body="> :x: **PR removed from merge queue**
>
> @$PR_AUTHOR your PR was removed from the merge queue. Fix the issue and re-queue when ready."

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 214 KiB

View File

@@ -297,7 +297,11 @@ function CustomTimePicker({
resetErrorStatus(); resetErrorStatus();
}; };
const handleInputPressEnter = (): void => { const handleInputPressEnter = (
event?: React.KeyboardEvent<HTMLInputElement>,
): void => {
event?.preventDefault();
event?.stopPropagation();
// check if the entered time is in the format of 1m, 2h, 3d, 4w // check if the entered time is in the format of 1m, 2h, 3d, 4w
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue); const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);

View File

@@ -1,5 +1,6 @@
// ** Helpers // ** Helpers
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas'; import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields'; import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName'; import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues'; import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -548,3 +549,49 @@ export const DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY: Record<
[DataTypes.ArrayBool]: 'boolAttributeValues', [DataTypes.ArrayBool]: 'boolAttributeValues',
[DataTypes.EMPTY]: 'stringAttributeValues', [DataTypes.EMPTY]: 'stringAttributeValues',
}; };
export const listViewInitialLogQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
aggregateOperator: LogsAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 100,
},
],
},
};
export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
[PANEL_TYPES.LIST]: listViewInitialLogQuery,
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
};
export const listViewInitialTraceQuery: Query = {
// it should be the above commented query
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: LogsAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},
};

View File

@@ -1,50 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
export const PANEL_TYPES_INITIAL_QUERY = {
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
[PANEL_TYPES.LIST]: initialQueriesMap.logs,
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
};
export const listViewInitialLogQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
aggregateOperator: LogsAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 100,
},
],
},
};
export const listViewInitialTraceQuery: Query = {
// it should be the above commented query
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: LogsAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},
};

View File

@@ -1,94 +0,0 @@
import { Card, Modal } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { PANEL_TYPES_INITIAL_QUERY } from './constants';
import menuItems from './menuItems';
import { Text } from './styles';
import './ComponentSlider.styles.scss';
function DashboardGraphSlider(): JSX.Element {
const { handleToggleDashboardSlider, isDashboardSliderOpen } = useDashboard();
const onClickHandler = (name: PANEL_TYPES) => (): void => {
const id = uuid();
handleToggleDashboardSlider(false);
logEvent('Dashboard Detail: New panel type selected', {
// dashboardId: '',
// dashboardName: '',
// numberOfPanels: 0, // todo - at this point we don't know these attributes
panelType: name,
widgetId: id,
});
const queryParamsLog = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify({
...PANEL_TYPES_INITIAL_QUERY[name],
builder: {
...PANEL_TYPES_INITIAL_QUERY[name].builder,
queryData: [
{
...PANEL_TYPES_INITIAL_QUERY[name].builder.queryData[0],
aggregateOperator: LogsAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 100,
},
],
},
}),
};
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
};
if (name === PANEL_TYPES.LIST) {
history.push(
`${history.location.pathname}/new?${createQueryParams(queryParamsLog)}`,
);
} else {
history.push(
`${history.location.pathname}/new?${createQueryParams(queryParams)}`,
);
}
};
const handleCardClick = (panelType: PANEL_TYPES): void => {
onClickHandler(panelType)();
};
return (
<Modal
open={isDashboardSliderOpen}
onCancel={(): void => {
handleToggleDashboardSlider(false);
}}
rootClassName="graph-selection"
footer={null}
title="New Panel"
>
<div className="panel-selection">
{menuItems.map(({ name, icon, display }) => (
<Card onClick={(): void => handleCardClick(name)} id={name} key={name}>
{icon}
<Text>{display}</Text>
</Card>
))}
</div>
</Modal>
);
}
export default DashboardGraphSlider;

View File

@@ -1,41 +0,0 @@
import { Card as CardComponent, Typography } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
justify-content: right;
gap: 8px;
margin-bottom: 12px;
`;
export const Card = styled(CardComponent)`
min-height: 80px;
min-width: 120px;
overflow-y: auto;
cursor: pointer;
transition: transform 0.2s;
.ant-card-body {
padding: 12px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
.ant-typography {
font-size: 12px;
font-weight: 600;
}
}
&:hover {
transform: scale(1.05);
border: 1px solid var(--bg-robin-400);
}
`;
export const Text = styled(Typography)`
text-align: center;
margin-top: 1rem;
`;

View File

@@ -182,9 +182,7 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation); (useLocation as jest.Mock).mockReturnValue(mockLocation);
const mockContextValue: IDashboardContext = { const mockContextValue: IDashboardContext = {
isDashboardSliderOpen: false,
isDashboardLocked: false, isDashboardLocked: false,
handleToggleDashboardSlider: jest.fn(),
handleDashboardLockToggle: jest.fn(), handleDashboardLockToggle: jest.fn(),
dashboardResponse: {} as IDashboardContext['dashboardResponse'], dashboardResponse: {} as IDashboardContext['dashboardResponse'],
selectedDashboard: (getDashboardById.data as unknown) as Dashboard, selectedDashboard: (getDashboardById.data as unknown) as Dashboard,

View File

@@ -40,6 +40,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { sortLayout } from 'providers/Dashboard/util'; import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll'; import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update'; import { Props } from 'types/api/dashboard/update';
@@ -48,10 +49,10 @@ import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader'; import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardGraphSlider from '../ComponentsSlider';
import DashboardSettings from '../DashboardSettings'; import DashboardSettings from '../DashboardSettings';
import { Base64Icons } from '../DashboardSettings/General/utils'; import { Base64Icons } from '../DashboardSettings/General/utils';
import DashboardVariableSelection from '../DashboardVariablesSelection'; import DashboardVariableSelection from '../DashboardVariablesSelection';
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
import SettingsDrawer from './SettingsDrawer'; import SettingsDrawer from './SettingsDrawer';
import { VariablesSettingsTab } from './types'; import { VariablesSettingsTab } from './types';
import { import {
@@ -69,6 +70,9 @@ interface DashboardDescriptionProps {
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { handle } = props; const { handle } = props;
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { const {
selectedDashboard, selectedDashboard,
panelMap, panelMap,
@@ -77,7 +81,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
setLayouts, setLayouts,
isDashboardLocked, isDashboardLocked,
setSelectedDashboard, setSelectedDashboard,
handleToggleDashboardSlider,
handleDashboardLockToggle, handleDashboardLockToggle,
} = useDashboard(); } = useDashboard();
@@ -145,14 +148,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const [addPanelPermission] = useComponentPermission(permissions, userRole); const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => { const onEmptyWidgetHandler = useCallback(() => {
handleToggleDashboardSlider(true); setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', { logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id, dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title, dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length, numberOfPanels: selectedDashboard?.data.widgets?.length,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleToggleDashboardSlider]); }, [setIsPanelTypeSelectionModalOpen]);
const handleLockDashboardToggle = (): void => { const handleLockDashboardToggle = (): void => {
setIsDashbordSettingsOpen(false); setIsDashbordSettingsOpen(false);
@@ -521,7 +524,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
<DashboardVariableSelection /> <DashboardVariableSelection />
</section> </section>
)} )}
<DashboardGraphSlider /> <PanelTypeSelectionModal />
<Modal <Modal
open={isRenameDashboardOpen} open={isRenameDashboardOpen}

View File

@@ -9,7 +9,6 @@ import {
} from 'hooks/dashboard/useDashboardVariables'; } from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl'; import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore'; import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { import {
enqueueDescendantsOfVariable, enqueueDescendantsOfVariable,
@@ -30,7 +29,7 @@ function DashboardVariableSelection(): JSX.Element | null {
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
} = useDashboard(); } = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl(); const { updateUrlVariable } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables(); const { dashboardVariables } = useDashboardVariables();
const dashboardId = useDashboardVariablesSelector( const dashboardId = useDashboardVariablesSelector(
@@ -50,15 +49,6 @@ function DashboardVariableSelection(): JSX.Element | null {
(state) => state.globalTime, (state) => state.globalTime,
); );
useEffect(() => {
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
getUrlVariables,
updateUrlVariable,
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
// Memoize the order key to avoid unnecessary triggers // Memoize the order key to avoid unnecessary triggers
const variableOrderKey = useMemo(() => { const variableOrderKey = useMemo(() => {
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? ''; const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';

View File

@@ -1,4 +1,4 @@
.graph-selection { .panel-type-selection-modal {
.ant-modal-content { .ant-modal-content {
width: 515px; width: 515px;
max-height: 646px; max-height: 646px;
@@ -76,6 +76,11 @@
content: none; content: none;
} }
} }
.panel-type-text {
text-align: center;
margin-top: 1rem;
}
} }
} }
@@ -114,7 +119,7 @@
} }
.lightMode { .lightMode {
.graph-selection { .panel-type-selection-modal {
.ant-modal-content { .ant-modal-content {
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);

View File

@@ -0,0 +1,68 @@
import { memo } from 'react';
import { Card, Modal, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { v4 as uuid } from 'uuid';
import { PanelTypesWithData } from './menuItems';
import './PanelTypeSelectionModal.styles.scss';
function PanelTypeSelectionModal(): JSX.Element {
const {
isPanelTypeSelectionModalOpen,
setIsPanelTypeSelectionModalOpen,
} = usePanelTypeSelectionModalStore();
const onClickHandler = (name: PANEL_TYPES) => (): void => {
const id = uuid();
setIsPanelTypeSelectionModalOpen(false);
logEvent('Dashboard Detail: New panel type selected', {
panelType: name,
widgetId: id,
});
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
};
history.push(
`${history.location.pathname}/new?${createQueryParams(queryParams)}`,
);
};
const handleCardClick = (panelType: PANEL_TYPES): void => {
onClickHandler(panelType)();
};
return (
<Modal
open={isPanelTypeSelectionModalOpen}
onCancel={(): void => {
setIsPanelTypeSelectionModalOpen(false);
}}
rootClassName="panel-type-selection-modal"
footer={null}
title="New Panel"
>
<div className="panel-selection">
{PanelTypesWithData.map(({ name, icon, display }) => (
<Card onClick={(): void => handleCardClick(name)} id={name} key={name}>
{icon}
<Typography className="panel-type-text">{display}</Typography>
</Card>
))}
</div>
</Modal>
);
}
export default memo(PanelTypeSelectionModal);

View File

@@ -9,7 +9,7 @@ import {
Table, Table,
} from 'lucide-react'; } from 'lucide-react';
const Items: ItemsProps[] = [ export const PanelTypesWithData: ItemsProps[] = [
{ {
name: PANEL_TYPES.TIME_SERIES, name: PANEL_TYPES.TIME_SERIES,
icon: <LineChart size={16} color={Color.BG_ROBIN_400} />, icon: <LineChart size={16} color={Color.BG_ROBIN_400} />,
@@ -52,5 +52,3 @@ export interface ItemsProps {
icon: JSX.Element; icon: JSX.Element;
display: string; display: string;
} }
export default Items;

View File

@@ -9,17 +9,18 @@ import DashboardSettings from 'container/DashboardContainer/DashboardSettings';
import useComponentPermission from 'hooks/useComponentPermission'; import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { ROLES, USER_ROLES } from 'types/roles'; import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission'; import { ComponentTypes } from 'utils/permission';
import './DashboardEmptyState.styles.scss'; import './DashboardEmptyState.styles.scss';
export default function DashboardEmptyState(): JSX.Element { export default function DashboardEmptyState(): JSX.Element {
const { const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
selectedDashboard, (s) => s.setIsPanelTypeSelectionModalOpen,
isDashboardLocked, );
handleToggleDashboardSlider,
} = useDashboard(); const { selectedDashboard, isDashboardLocked } = useDashboard();
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null); const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState<boolean>( const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState<boolean>(
@@ -41,14 +42,14 @@ export default function DashboardEmptyState(): JSX.Element {
const [addPanelPermission] = useComponentPermission(permissions, userRole); const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => { const onEmptyWidgetHandler = useCallback(() => {
handleToggleDashboardSlider(true); setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', { logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id, dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title, dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length, numberOfPanels: selectedDashboard?.data.widgets?.length,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleToggleDashboardSlider]); }, [setIsPanelTypeSelectionModalOpen]);
const onConfigureClick = useCallback((): void => { const onConfigureClick = useCallback((): void => {
setIsSettingsDrawerOpen(true); setIsSettingsDrawerOpen(true);

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { Select, Typography } from 'antd'; import { Select, Typography } from 'antd';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import GraphTypes from 'container/DashboardContainer/ComponentsSlider/menuItems'; import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
import { handleQueryChange } from 'container/NewWidget/utils'; import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -59,7 +59,7 @@ function PanelTypeSelector({
data-testid="panel-change-select" data-testid="panel-change-select"
disabled={disabled} disabled={disabled}
> >
{GraphTypes.map((item) => ( {PanelTypesWithData.map((item) => (
<Option key={item.name} value={item.name}> <Option key={item.name} value={item.name}>
<div className="view-panel-select-option"> <div className="view-panel-select-option">
<div className="icon">{item.icon}</div> <div className="icon">{item.icon}</div>

View File

@@ -5,6 +5,7 @@ import useComponentPermission from 'hooks/useComponentPermission';
import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react'; import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { setSelectedRowWidgetId } from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper'; import { setSelectedRowWidgetId } from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper';
import { ROLES, USER_ROLES } from 'types/roles'; import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission'; import { ComponentTypes } from 'utils/permission';
@@ -34,11 +35,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
} = props; } = props;
const [isRowSettingsOpen, setIsRowSettingsOpen] = useState<boolean>(false); const [isRowSettingsOpen, setIsRowSettingsOpen] = useState<boolean>(false);
const { const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
handleToggleDashboardSlider, (s) => s.setIsPanelTypeSelectionModalOpen,
selectedDashboard, );
isDashboardLocked,
} = useDashboard(); const { selectedDashboard, isDashboardLocked } = useDashboard();
const permissions: ComponentTypes[] = ['add_panel']; const permissions: ComponentTypes[] = ['add_panel'];
const { user } = useAppContext(); const { user } = useAppContext();
@@ -87,7 +88,7 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
} }
setSelectedRowWidgetId(selectedDashboard.id, id); setSelectedRowWidgetId(selectedDashboard.id, id);
handleToggleDashboardSlider(true); setIsPanelTypeSelectionModalOpen(true);
}} }}
> >
New Panel New Panel

View File

@@ -15,6 +15,7 @@ import ROUTES from 'constants/routes';
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils'; import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList'; import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange'; import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history'; import history from 'lib/history';
import cloneDeep from 'lodash-es/cloneDeep'; import cloneDeep from 'lodash-es/cloneDeep';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
@@ -43,6 +44,7 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element { export default function Home(): JSX.Element {
const { user } = useAppContext(); const { user } = useAppContext();
const isDarkMode = useIsDarkMode();
const [startTime, setStartTime] = useState<number | null>(null); const [startTime, setStartTime] = useState<number | null>(null);
const [endTime, setEndTime] = useState<number | null>(null); const [endTime, setEndTime] = useState<number | null>(null);
@@ -680,7 +682,11 @@ export default function Home(): JSX.Element {
<div className="checklist-img-container"> <div className="checklist-img-container">
<img <img
src="/Images/allInOne.svg" src={
isDarkMode
? '/Images/allInOne.svg'
: '/Images/allInOneLightMode.svg'
}
alt="checklist-img" alt="checklist-img"
className="checklist-img" className="checklist-img"
/> />

View File

@@ -1,5 +1,7 @@
.column-unit-selector { .column-unit-selector {
margin-top: 16px; display: flex;
flex-direction: column;
gap: 8px;
.heading { .heading {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -30,6 +32,11 @@
width: 100%; width: 100%;
} }
} }
&-content {
display: flex;
flex-direction: column;
gap: 12px;
}
} }
.lightMode { .lightMode {

View File

@@ -72,22 +72,24 @@ export function ColumnUnitSelector(
return ( return (
<section className="column-unit-selector"> <section className="column-unit-selector">
<Typography.Text className="heading">Column Units</Typography.Text> <Typography.Text className="heading">Column Units</Typography.Text>
{aggregationQueries.map(({ value, label }) => { <div className="column-unit-selector-content">
const baseQueryName = value.split('.')[0]; {aggregationQueries.map(({ value, label }) => {
return ( const baseQueryName = value.split('.')[0];
<YAxisUnitSelectorV2 return (
value={columnUnits[value] || ''} <YAxisUnitSelectorV2
onSelect={(unitValue: string): void => value={columnUnits[value] || ''}
handleColumnUnitSelect(value, unitValue) onSelect={(unitValue: string): void =>
} handleColumnUnitSelect(value, unitValue)
fieldLabel={label} }
key={value} fieldLabel={label}
selectedQueryName={baseQueryName} key={value}
// Update the column unit value automatically only in create mode selectedQueryName={baseQueryName}
shouldUpdateYAxisUnit={isNewDashboard} // Update the column unit value automatically only in create mode
/> shouldUpdateYAxisUnit={isNewDashboard}
); />
})} );
})}
</div>
</section> </section>
); );
} }

View File

@@ -56,9 +56,6 @@ describe('ContextLinks Component', () => {
/>, />,
); );
// Check that the component renders
expect(screen.getByText('Context Links')).toBeInTheDocument();
// Check that the add button is present // Check that the add button is present
expect( expect(
screen.getByRole('button', { name: /context link/i }), screen.getByRole('button', { name: /context link/i }),

View File

@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Typography } from 'antd'; import { Button, Modal } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'; import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import { import {
@@ -134,11 +134,16 @@ function ContextLinks({
return ( return (
<div className="context-links-container"> <div className="context-links-container">
<Typography.Text className="context-links-text">
Context Links
</Typography.Text>
<div className="context-links-list"> <div className="context-links-list">
<Button
type="default"
className="add-context-link-button"
icon={<Plus size={12} />}
style={{ width: '100%' }}
onClick={handleAddContextLink}
>
Add Context Link
</Button>
<OverlayScrollbar> <OverlayScrollbar>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -160,16 +165,6 @@ function ContextLinks({
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</OverlayScrollbar> </OverlayScrollbar>
{/* button to add context link */}
<Button
type="primary"
className="add-context-link-button"
icon={<Plus size={12} />}
onClick={handleAddContextLink}
>
Context Link
</Button>
</div> </div>
<Modal <Modal

View File

@@ -2,7 +2,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
margin: 12px;
} }
.context-links-text { .context-links-text {
@@ -110,10 +109,7 @@
} }
.add-context-link-button { .add-context-link-button {
display: flex; width: 100%;
align-items: center;
margin: auto;
width: fit-content;
} }
.lightMode { .lightMode {

View File

@@ -1,6 +1,7 @@
.right-container { .right-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 48px;
.header { .header {
display: flex; display: flex;
@@ -24,14 +25,14 @@
letter-spacing: -0.07px; letter-spacing: -0.07px;
} }
} }
.control-container {
.name-description {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--bg-slate-500);
gap: 8px; gap: 8px;
}
.name-description {
padding: 0 0 4px 0;
.typography { .typography {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -88,9 +89,6 @@
.panel-config { .panel-config {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 12px 16px 12px;
gap: 8px;
border-bottom: 1px solid var(--bg-slate-500);
.typography { .typography {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -104,6 +102,7 @@
} }
.panel-type-select { .panel-type-select {
width: 100%;
.ant-select-selector { .ant-select-selector {
display: flex; display: flex;
height: 32px; height: 32px;
@@ -137,7 +136,6 @@
} }
.fill-gaps { .fill-gaps {
margin-top: 16px;
display: flex; display: flex;
padding: 12px; padding: 12px;
justify-content: space-between; justify-content: space-between;
@@ -156,31 +154,24 @@
letter-spacing: 0.52px; letter-spacing: 0.52px;
text-transform: uppercase; text-transform: uppercase;
} }
.fill-gaps-text-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
opacity: 0.6;
line-height: 16px; /* 133.333% */
}
} }
.log-scale, .log-scale,
.decimal-precision-selector { .decimal-precision-selector,
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-position { .legend-position {
margin-top: 16px;
display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-colors {
margin-top: 16px;
} }
.panel-time-text { .panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
font-size: 13px; font-size: 13px;
@@ -193,7 +184,6 @@
.y-axis-unit-selector, .y-axis-unit-selector,
.y-axis-unit-selector-v2 { .y-axis-unit-selector-v2 {
margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -278,11 +268,8 @@
} }
.stack-chart { .stack-chart {
margin-top: 16px; flex-direction: row;
display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px;
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
@@ -296,11 +283,6 @@
} }
.bucket-config { .bucket-config {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
@@ -352,16 +334,13 @@
} }
} }
.context-links {
border-bottom: 1px solid var(--bg-slate-500);
}
.alerts { .alerts {
display: flex; display: flex;
padding: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid var(--bg-slate-500); padding: 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer; cursor: pointer;
.left-section { .left-section {
@@ -387,6 +366,16 @@
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
} }
} }
.context-links {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
} }
.select-option { .select-option {
@@ -418,9 +407,6 @@
} }
.name-description { .name-description {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.typography { .typography {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
@@ -441,8 +427,6 @@
} }
.panel-config { .panel-config {
border-bottom: 1px solid var(--bg-vanilla-300);
.typography { .typography {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
@@ -478,6 +462,9 @@
.fill-gaps-text { .fill-gaps-text {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
.fill-gaps-text-description {
color: var(--bg-ink-400);
}
} }
.bucket-config { .bucket-config {
@@ -530,7 +517,7 @@
} }
.alerts { .alerts {
border-bottom: 1px solid var(--bg-vanilla-300); border-top: 1px solid var(--bg-vanilla-300);
.left-section { .left-section {
.bell-icon { .bell-icon {
@@ -549,6 +536,10 @@
.context-links { .context-links {
border-bottom: 1px solid var(--bg-vanilla-300); border-bottom: 1px solid var(--bg-vanilla-300);
} }
.thresholds-section {
border-top: 1px solid var(--bg-vanilla-300);
}
} }
.select-option { .select-option {

View File

@@ -1,5 +1,4 @@
.threshold-selector-container { .threshold-selector-container {
padding: 12px;
padding-bottom: 80px; padding-bottom: 80px;
.threshold-select { .threshold-select {

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import { Typography } from 'antd'; import { Button } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryLabels } from 'hooks/useGetQueryLabels'; import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
import { Antenna, Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import Threshold from './Threshold'; import Threshold from './Threshold';
@@ -68,11 +68,14 @@ function ThresholdSelector({
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="threshold-selector-container"> <div className="threshold-selector-container">
<div className="threshold-select" onClick={addThresholdHandler}> <div className="threshold-select" onClick={addThresholdHandler}>
<div className="left-section"> <Button
<Antenna size={14} className="icon" /> type="default"
<Typography.Text className="text">Thresholds</Typography.Text> icon={<Plus size={14} />}
</div> style={{ width: '100%' }}
<Plus size={14} onClick={addThresholdHandler} className="icon" /> onClick={addThresholdHandler}
>
Add Threshold
</Button>
</div> </div>
{thresholds.map((threshold, idx) => ( {thresholds.map((threshold, idx) => (
<Threshold <Threshold

View File

@@ -0,0 +1,68 @@
.settings-section {
border-top: 1px solid var(--bg-slate-500);
}
.settings-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 12px;
min-height: 44px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
.settings-section-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
}
.chevron-icon {
color: var(--bg-vanilla-400);
transition: transform 0.2s ease-in-out;
&.open {
transform: rotate(180deg);
}
}
}
.settings-section-content {
padding: 0 12px 0 12px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease;
&.open {
padding-bottom: 24px;
max-height: 1000px;
opacity: 1;
}
}
.lightMode {
.settings-section-header {
.chevron-icon {
color: var(--bg-ink-400);
}
.settings-section-title {
color: var(--bg-ink-400);
}
}
.settings-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,51 @@
import { ReactNode, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import './SettingsSection.styles.scss';
export interface SettingsSectionProps {
title: string;
defaultOpen?: boolean;
children: ReactNode;
icon?: ReactNode;
}
function SettingsSection({
title,
defaultOpen = false,
children,
icon,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggleOpen = (): void => {
setIsOpen((prev) => !prev);
};
return (
<section className="settings-section">
<button
type="button"
className="settings-section-header"
onClick={toggleOpen}
>
<span className="settings-section-title">
{icon ? icon : null} {title}
</span>
<ChevronDown
size={16}
className={isOpen ? 'chevron-icon open' : 'chevron-icon'}
/>
</button>
<div
className={
isOpen ? 'settings-section-content open' : 'settings-section-content'
}
>
{children}
</div>
</section>
);
}
export default SettingsSection;

View File

@@ -14,23 +14,30 @@ import {
Input, Input,
InputNumber, InputNumber,
Select, Select,
Space,
Switch, Switch,
Typography, Typography,
} from 'antd'; } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types'; import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import TimePreference from 'components/TimePreferenceDropDown'; import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder'; import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import GraphTypes, { import {
ItemsProps, ItemsProps,
} from 'container/DashboardContainer/ComponentsSlider/menuItems'; PanelTypesWithData,
} from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables'; import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts'; import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { import {
Antenna,
Axis3D,
ConciergeBell, ConciergeBell,
Layers,
LayoutDashboard,
LineChart, LineChart,
Link,
Pencil,
Plus, Plus,
SlidersHorizontal,
Spline, Spline,
SquareArrowOutUpRight, SquareArrowOutUpRight,
} from 'lucide-react'; } from 'lucide-react';
@@ -46,6 +53,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector'; import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import SettingsSection from './components/SettingsSection/SettingsSection';
import { import {
panelTypeVsBucketConfig, panelTypeVsBucketConfig,
panelTypeVsColumnUnitPreferences, panelTypeVsColumnUnitPreferences,
@@ -143,7 +151,7 @@ function RightContainer({
); );
const selectedGraphType = const selectedGraphType =
GraphTypes.find((e) => e.name === selectedGraph)?.display || ''; PanelTypesWithData.find((e) => e.name === selectedGraph)?.display || '';
const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView'); const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView');
@@ -169,7 +177,7 @@ function RightContainer({
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes); const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(PanelTypesWithData);
const dashboardVariableOptions = useMemo<VariableOption[]>(() => { const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
return Object.entries(dashboardVariables).map(([, value]) => ({ return Object.entries(dashboardVariables).map(([, value]) => ({
@@ -178,6 +186,21 @@ function RightContainer({
})); }));
}, [dashboardVariables]); }, [dashboardVariables]);
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
allowSoftMinMax,
allowLogScale,
]);
const isFormattingSectionVisible = useMemo(
() => allowYAxisUnit || allowDecimalPrecision || allowPanelColumnPreference,
[allowYAxisUnit, allowDecimalPrecision, allowPanelColumnPreference],
);
const isLegendSectionVisible = useMemo(
() => allowLegendPosition || allowLegendColors,
[allowLegendPosition, allowLegendColors],
);
const updateCursorAndDropdown = (value: string, pos: number): void => { const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos); setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1); const lastDollar = value.lastIndexOf('$', pos - 1);
@@ -193,6 +216,15 @@ function RightContainer({
}, 0); }, 0);
}; };
const decimapPrecisionOptions = useMemo(() => {
return [
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
];
}, []);
const handleInputCursor = (): void => { const handleInputCursor = (): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0; const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos); updateCursorAndDropdown(inputValue, pos);
@@ -241,7 +273,7 @@ function RightContainer({
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST), prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
); );
} else { } else {
setGraphTypes(GraphTypes); setGraphTypes(PanelTypesWithData);
} }
}, [currentQuery]); }, [currentQuery]);
@@ -263,269 +295,297 @@ function RightContainer({
<div className="right-container"> <div className="right-container">
<section className="header"> <section className="header">
<div className="purple-dot" /> <div className="purple-dot" />
<Typography.Text className="header-text">Panel details</Typography.Text> <Typography.Text className="header-text">Panel Settings</Typography.Text>
</section> </section>
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
rootClassName="description-input"
/>
</section>
<section className="panel-config">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
style={{ width: '100%' }}
className="panel-type-select"
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
{allowFillSpans && ( <SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
<Space className="fill-gaps"> <section className="name-description control-container">
<Typography className="fill-gaps-text">Fill gaps</Typography> <Typography.Text className="typography">Name</Typography.Text>
<Switch <AutoComplete
checked={isFillSpans} options={dashboardVariableOptions}
size="small" value={inputValue}
onChange={(checked): void => setIsFillSpans(checked)} onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/> />
</Space> </AutoComplete>
)} <Typography.Text className="typography">Description</Typography.Text>
<TextArea
{allowPanelTimePreference && ( placeholder="Enter the panel description here..."
<> bordered
<Typography.Text className="panel-time-text"> allowClear
Panel Time Preference value={description}
</Typography.Text> onChange={(event): void =>
<TimePreference onChangeHandler(setDescription, event.target.value)
{...{
selectedTime,
setSelectedTime,
}}
/>
</>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
} }
// Only update the y-axis unit value automatically in create mode rootClassName="description-input"
shouldUpdateYAxisUnit={isNewDashboard}
/> />
)} </section>
</SettingsSection>
{allowDecimalPrecision && ( <section className="panel-config">
<section className="decimal-precision-selector"> <SettingsSection
<Typography.Text className="typography"> title="Visualization"
Decimal Precision defaultOpen
</Typography.Text> icon={<LayoutDashboard size={14} />}
>
<section className="panel-type control-container">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select <Select
options={[ onChange={setGraphHandler}
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO }, value={selectedGraph}
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
]}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select" className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO} data-testid="panel-change-select"
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)} data-stacking-state={stackedBarChart ? 'true' : 'false'}
/> >
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
</section> </section>
)}
{allowSoftMinMax && ( {allowPanelTimePreference && (
<section className="soft-min-max"> <section className="panel-time-preference control-container">
<section className="container"> <Typography.Text className="panel-time-text">
<Typography.Text className="text">Soft Min</Typography.Text> Panel Time Preference
<InputNumber </Typography.Text>
type="number" <TimePreference
value={softMin} {...{
onChange={softMinHandler} selectedTime,
rootClassName="input" setSelectedTime,
}}
/> />
</section> </section>
<section className="container"> )}
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber {allowStackingBarChart && (
value={softMax} <section className="stack-chart control-container">
type="number" <Typography.Text className="label">Stack series</Typography.Text>
rootClassName="input" <Switch
onChange={softMaxHandler} checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/> />
</section> </section>
</section> )}
{allowFillSpans && (
<section className="fill-gaps">
<div className="fill-gaps-text-container">
<Typography className="fill-gaps-text">Fill gaps</Typography>
<Typography.Text className="fill-gaps-text-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
)}
</SettingsSection>
{isFormattingSectionVisible && (
<SettingsSection
title="Formatting & Units"
icon={<SlidersHorizontal size={14} />}
>
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector control-container">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<Select
options={decimapPrecisionOptions}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
)} )}
{allowStackingBarChart && ( {isAxisSectionVisible && (
<section className="stack-chart"> <SettingsSection title="Axes" icon={<Axis3D size={14} />}>
<Typography.Text className="label">Stack series</Typography.Text> {allowSoftMinMax && (
<Switch <section className="soft-min-max">
checked={stackedBarChart} <section className="container">
size="small" <Typography.Text className="text">Soft Min</Typography.Text>
onChange={(checked): void => setStackedBarChart(checked)} <InputNumber
/> type="number"
</section> value={softMin}
onChange={softMinHandler}
rootClassName="input"
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
/>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale control-container">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void =>
setIsLogScale(value === LogScale.LOGARITHMIC)
}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
</SettingsSection>
)}
{isLegendSectionVisible && (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="typography">Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</SettingsSection>
)} )}
{allowBucketConfig && ( {allowBucketConfig && (
<section className="bucket-config"> <SettingsSection title="Histogram / Buckets">
<Typography.Text className="label">Number of buckets</Typography.Text> <section className="bucket-config control-container">
<InputNumber <Typography.Text className="label">Number of buckets</Typography.Text>
value={bucketCount || null} <InputNumber
type="number" value={bucketCount || null}
min={0} type="number"
rootClassName="bucket-input" min={0}
placeholder="Default: 30" rootClassName="bucket-input"
onChange={(val): void => { placeholder="Default: 30"
setBucketCount(val || 0); onChange={(val): void => {
}} setBucketCount(val || 0);
/> }}
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/> />
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section> </section>
</section> </SettingsSection>
)}
{allowLogScale && (
<section className="log-scale">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendPosition && (
<section className="legend-position">
<Typography.Text className="typography">Legend Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)} )}
</section> </section>
@@ -541,17 +601,25 @@ function RightContainer({
)} )}
{allowContextLinks && ( {allowContextLinks && (
<section className="context-links"> <SettingsSection
title="Context Links"
icon={<Link size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<ContextLinks <ContextLinks
contextLinks={contextLinks} contextLinks={contextLinks}
setContextLinks={setContextLinks} setContextLinks={setContextLinks}
selectedWidget={selectedWidget} selectedWidget={selectedWidget}
/> />
</section> </SettingsSection>
)} )}
{allowThreshold && ( {allowThreshold && (
<section> <SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector <ThresholdSelector
thresholds={thresholds} thresholds={thresholds}
setThresholds={setThresholds} setThresholds={setThresholds}
@@ -559,7 +627,7 @@ function RightContainer({
selectedGraph={selectedGraph} selectedGraph={selectedGraph}
columnUnits={columnUnits} columnUnits={columnUnits}
/> />
</section> </SettingsSection>
)} )}
</div> </div>
); );

View File

@@ -36,7 +36,7 @@ const checkStackSeriesState = (
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument(); expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
const stackSeriesSection = container.querySelector( const stackSeriesSection = container.querySelector(
'section > .stack-chart', '.stack-chart',
) as HTMLElement; ) as HTMLElement;
expect(stackSeriesSection).toBeInTheDocument(); expect(stackSeriesSection).toBeInTheDocument();
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
expect(getByText('Stack series')).toBeInTheDocument(); expect(getByText('Stack series')).toBeInTheDocument();
// Verify section exists // Verify section exists
const section = container.querySelector('section > .stack-chart'); const section = container.querySelector('.stack-chart');
expect(section).toBeInTheDocument(); expect(section).toBeInTheDocument();
// Verify switch is present and enabled (ant-switch-checked) // Verify switch is present and enabled (ant-switch-checked)

View File

@@ -439,6 +439,19 @@ function NewWidget({
globalSelectedInterval, globalSelectedInterval,
]); ]);
const navigateToDashboardPage = useCallback(() => {
const params = new URLSearchParams();
const urlVariablesQueryString = query.get(QueryParams.variables);
if (urlVariablesQueryString) {
params.set(QueryParams.variables, urlVariablesQueryString);
}
const search = params.toString() ? `?${params.toString()}` : '';
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }) + search);
}, [dashboardId, query, safeNavigate]);
const onClickSaveHandler = useCallback(() => { const onClickSaveHandler = useCallback(() => {
if (!selectedDashboard) { if (!selectedDashboard) {
return; return;
@@ -554,9 +567,7 @@ function NewWidget({
updateDashboardMutation.mutateAsync(dashboard, { updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: () => { onSuccess: () => {
setToScrollWidgetId(selectedWidget?.id || ''); setToScrollWidgetId(selectedWidget?.id || '');
safeNavigate({ navigateToDashboardPage();
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
});
}, },
}); });
}, [ }, [
@@ -572,7 +583,7 @@ function NewWidget({
updateDashboardMutation, updateDashboardMutation,
widgets, widgets,
setToScrollWidgetId, setToScrollWidgetId,
safeNavigate, navigateToDashboardPage,
dashboardId, dashboardId,
]); ]);
@@ -581,12 +592,12 @@ function NewWidget({
setDiscardModal(true); setDiscardModal(true);
return; return;
} }
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId })); navigateToDashboardPage();
}, [dashboardId, isQueryModified, safeNavigate]); }, [isQueryModified, navigateToDashboardPage]);
const discardChanges = useCallback(() => { const discardChanges = useCallback(() => {
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId })); navigateToDashboardPage();
}, [dashboardId, safeNavigate]); }, [navigateToDashboardPage]);
const setGraphHandler = (type: PANEL_TYPES): void => { const setGraphHandler = (type: PANEL_TYPES): void => {
setIsLoadingPanelData(true); setIsLoadingPanelData(true);
@@ -728,12 +739,14 @@ function NewWidget({
} }
const widgetId = query.get('widgetId') || ''; const widgetId = query.get('widgetId') || '';
const graphType = query.get('graphType') || ''; const graphType = query.get('graphType') || '';
const variables = query.get(QueryParams.variables) || '';
const queryParams = { const queryParams = {
[QueryParams.expandedWidgetId]: widgetId, [QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType, [QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent( [QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery), JSON.stringify(currentQuery),
), ),
[QueryParams.variables]: variables,
}; };
const updatedSearch = createQueryParams(queryParams); const updatedSearch = createQueryParams(queryParams);
@@ -822,56 +835,54 @@ function NewWidget({
</LeftContainerWrapper> </LeftContainerWrapper>
<RightContainerWrapper> <RightContainerWrapper>
<OverlayScrollbar> <RightContainer
<RightContainer setGraphHandler={setGraphHandler}
setGraphHandler={setGraphHandler} title={title}
title={title} setTitle={setTitle}
setTitle={setTitle} description={description}
description={description} setDescription={setDescription}
setDescription={setDescription} stackedBarChart={stackedBarChart}
stackedBarChart={stackedBarChart} setStackedBarChart={setStackedBarChart}
setStackedBarChart={setStackedBarChart} opacity={opacity}
opacity={opacity} yAxisUnit={yAxisUnit}
yAxisUnit={yAxisUnit} columnUnits={columnUnits}
columnUnits={columnUnits} setColumnUnits={setColumnUnits}
setColumnUnits={setColumnUnits} bucketCount={bucketCount}
bucketCount={bucketCount} bucketWidth={bucketWidth}
bucketWidth={bucketWidth} combineHistogram={combineHistogram}
combineHistogram={combineHistogram} setCombineHistogram={setCombineHistogram}
setCombineHistogram={setCombineHistogram} setBucketWidth={setBucketWidth}
setBucketWidth={setBucketWidth} setBucketCount={setBucketCount}
setBucketCount={setBucketCount} setOpacity={setOpacity}
setOpacity={setOpacity} selectedNullZeroValue={selectedNullZeroValue}
selectedNullZeroValue={selectedNullZeroValue} setSelectedNullZeroValue={setSelectedNullZeroValue}
setSelectedNullZeroValue={setSelectedNullZeroValue} selectedGraph={graphType}
selectedGraph={graphType} setSelectedTime={setSelectedTime}
setSelectedTime={setSelectedTime} selectedTime={selectedTime}
selectedTime={selectedTime} setYAxisUnit={setYAxisUnit}
setYAxisUnit={setYAxisUnit} decimalPrecision={decimalPrecision}
decimalPrecision={decimalPrecision} setDecimalPrecision={setDecimalPrecision}
setDecimalPrecision={setDecimalPrecision} thresholds={thresholds}
thresholds={thresholds} setThresholds={setThresholds}
setThresholds={setThresholds} selectedWidget={selectedWidget}
selectedWidget={selectedWidget} isFillSpans={isFillSpans}
isFillSpans={isFillSpans} setIsFillSpans={setIsFillSpans}
setIsFillSpans={setIsFillSpans} isLogScale={isLogScale}
isLogScale={isLogScale} setIsLogScale={setIsLogScale}
setIsLogScale={setIsLogScale} legendPosition={legendPosition}
legendPosition={legendPosition} setLegendPosition={setLegendPosition}
setLegendPosition={setLegendPosition} customLegendColors={customLegendColors}
customLegendColors={customLegendColors} setCustomLegendColors={setCustomLegendColors}
setCustomLegendColors={setCustomLegendColors} queryResponse={queryResponse}
queryResponse={queryResponse} softMin={softMin}
softMin={softMin} setSoftMin={setSoftMin}
setSoftMin={setSoftMin} softMax={softMax}
softMax={softMax} setSoftMax={setSoftMax}
setSoftMax={setSoftMax} contextLinks={contextLinks}
contextLinks={contextLinks} setContextLinks={setContextLinks}
setContextLinks={setContextLinks} enableDrillDown={enableDrillDown}
enableDrillDown={enableDrillDown} isNewDashboard={isNewDashboard}
isNewDashboard={isNewDashboard} />
/>
</OverlayScrollbar>
</RightContainerWrapper> </RightContainerWrapper>
</PanelContainer> </PanelContainer>
<Modal <Modal

View File

@@ -15,7 +15,14 @@ export const RightContainerWrapper = styled(Col)`
overflow-y: auto; overflow-y: auto;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0rem; width: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
} }
`; `;

View File

@@ -11,11 +11,8 @@ import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import { import {
initialQueryBuilderFormValuesMap, initialQueryBuilderFormValuesMap,
PANEL_TYPES, PANEL_TYPES,
} from 'constants/queryBuilder';
import {
listViewInitialLogQuery,
PANEL_TYPES_INITIAL_QUERY, PANEL_TYPES_INITIAL_QUERY,
} from 'container/DashboardContainer/ComponentsSlider/constants'; } from 'constants/queryBuilder';
import { import {
defaultLogsSelectedColumns, defaultLogsSelectedColumns,
defaultTraceSelectedColumns, defaultTraceSelectedColumns,
@@ -549,10 +546,7 @@ export const getDefaultWidgetData = (
nullZeroValues: '', nullZeroValues: '',
opacity: '', opacity: '',
panelTypes: name, panelTypes: name,
query: query: PANEL_TYPES_INITIAL_QUERY[name],
name === PANEL_TYPES.LIST
? listViewInitialLogQuery
: PANEL_TYPES_INITIAL_QUERY[name],
timePreferance: 'GLOBAL_TIME', timePreferance: 'GLOBAL_TIME',
softMax: null, softMax: null,
softMin: null, softMin: null,

View File

@@ -0,0 +1,339 @@
import { renderHook } from '@testing-library/react';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
jest.mock('hooks/dashboard/useVariablesFromUrl');
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
typeof useDashboardVariablesFromLocalStorage
>;
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
typeof useVariablesFromUrl
>;
const makeVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'existing-id',
name: 'env',
description: '',
type: 'QUERY',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
selectedValue: 'prod',
...overrides,
});
const makeDashboard = (
variables: Record<string, IDashboardVariable>,
): Dashboard => ({
id: 'dash-1',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'Test',
variables,
},
});
const setupHook = (
currentDashboard: Record<string, any> = {},
urlVariables: Record<string, any> = {},
): ReturnType<typeof useTransformDashboardVariables> => {
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
currentDashboard,
updateLocalStorageDashboardVariables: jest.fn(),
});
mockUseVariablesFromUrl.mockReturnValue({
getUrlVariables: () => urlVariables,
setUrlVariables: jest.fn(),
updateUrlVariable: jest.fn(),
});
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
return result.current;
};
describe('useTransformDashboardVariables', () => {
beforeEach(() => jest.clearAllMocks());
describe('order assignment', () => {
it('assigns order starting from 0 to variables that have none', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
});
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
expect(orders).toContain(0);
expect(orders).toContain(1);
});
it('preserves existing order values', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.order).toBe(5);
});
it('assigns unique orders across multiple variables that all lack an order', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
});
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
// All three newly assigned orders must be distinct
expect(new Set(orders).size).toBe(3);
});
});
describe('ID assignment', () => {
it('assigns a UUID to variables that have no id', () => {
const { transformDashboardVariables } = setupHook();
const variable = makeVariable({ name: 'v1' });
(variable as any).id = undefined;
const dashboard = makeDashboard({ v1: variable });
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('preserves existing IDs', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toBe('keep-me');
});
});
describe('TEXTBOX backward compatibility', () => {
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'v1',
type: 'TEXTBOX',
textboxValue: 'hello',
defaultValue: undefined,
order: undefined,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('hello');
});
it('does not overwrite an existing defaultValue', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'v1',
type: 'TEXTBOX',
textboxValue: 'old',
defaultValue: 'keep',
order: undefined,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('keep');
});
});
describe('localStorage merge', () => {
it('applies localStorage selectedValue over DB value', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: 'staging', allSelected: false },
});
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('staging');
});
it('applies localStorage allSelected over DB value', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: undefined, allSelected: true },
});
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
allSelected: false,
showALLOption: true,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
});
});
describe('URL variable override', () => {
it('sets allSelected=true when URL value is __ALL__', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: 'prod', allSelected: false } },
{ env: '__ALL__' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: true,
allSelected: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
});
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: undefined, allSelected: true } },
{ env: 'dev' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: true,
allSelected: true,
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(false);
});
it('does not set allSelected=false when showALLOption is false', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: undefined, allSelected: true } },
{ env: 'dev' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: false,
allSelected: true,
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(true);
});
it('normalizes array URL value to single value for single-select variable', () => {
const { transformDashboardVariables } = setupHook(
{},
{ env: ['prod', 'dev'] },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('prod');
});
it('wraps single URL value in array for multi-select variable', () => {
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
multiSelect: true,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
});
it('looks up URL variable by variable id when name is absent', () => {
const { transformDashboardVariables } = setupHook(
{},
{ 'var-uuid': 'fallback' },
);
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
delete variable.name;
const dashboard = makeDashboard({ v1: variable });
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('fallback');
});
});
describe('edge cases', () => {
it('returns data unchanged when there are no variables', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables).toEqual({});
});
it('does not mutate the original dashboard', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: 'staging', allSelected: false },
});
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const originalValue = dashboard.data.variables.v1.selectedValue;
transformDashboardVariables(dashboard);
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
});
});
});

View File

@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
[id: string]: LocalStoreDashboardVariables; [id: string]: LocalStoreDashboardVariables;
} }
interface UseDashboardVariablesFromLocalStorageReturn { export interface UseDashboardVariablesFromLocalStorageReturn {
currentDashboard: LocalStoreDashboardVariables; currentDashboard: LocalStoreDashboardVariables;
updateLocalStorageDashboardVariables: ( updateLocalStorageDashboardVariables: (
id: string, id: string,

View File

@@ -0,0 +1,128 @@
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import {
useDashboardVariablesFromLocalStorage,
UseDashboardVariablesFromLocalStorageReturn,
} from 'hooks/dashboard/useDashboardFromLocalStorage';
import useVariablesFromUrl, {
UseVariablesFromUrlReturn,
} from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as generateUUID } from 'uuid';
export function useTransformDashboardVariables(
dashboardId: string,
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
UseDashboardVariablesFromLocalStorageReturn & {
transformDashboardVariables: (data: Dashboard) => Dashboard;
} {
const {
currentDashboard,
updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId);
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (!isEmpty(urlVariable)) {
if (urlVariable === ALL_SELECTED_VALUE) {
updatedVariable = {
...updatedVariable,
allSelected: true,
};
} else {
// Normalize URL value to match variable's multiSelect configuration
const normalizedValue = normalizeUrlValueForVariable(
urlVariable,
variableData,
);
updatedVariable = {
...updatedVariable,
selectedValue: normalizedValue,
// Only set allSelected to false if showALLOption is available
...(updatedVariable?.showALLOption && { allSelected: false }),
};
}
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
}
return updatedData;
};
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
// eslint-disable-next-line sonarjs/cognitive-complexity
const transformDashboardVariables = (data: Dashboard): Dashboard => {
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
for (const key in variables) {
// eslint-disable-next-line no-prototype-builtins
if (variables.hasOwnProperty(key)) {
const variable: IDashboardVariable = variables[key];
// Check if 'order' property doesn't exist or is undefined
if (variable.order === undefined) {
// Find a unique order starting from 0
let order = 0;
while (existingOrders.has(order)) {
order += 1;
}
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {
variable.id = generateUUID();
}
}
}
return clonedDashboardData;
}
return data;
};
return {
transformDashboardVariables,
getUrlVariables,
updateUrlVariable,
currentDashboard,
updateLocalStorageDashboardVariables,
};
}

View File

@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
| IDashboardVariable['selectedValue']; | IDashboardVariable['selectedValue'];
} }
interface UseVariablesFromUrlReturn { export interface UseVariablesFromUrlReturn {
getUrlVariables: () => LocalStoreDashboardVariables; getUrlVariables: () => LocalStoreDashboardVariables;
setUrlVariables: (variables: LocalStoreDashboardVariables) => void; setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
updateUrlVariable: ( updateUrlVariable: (

View File

@@ -12,6 +12,8 @@ import {
ATTRIBUTE_TYPES, ATTRIBUTE_TYPES,
initialAutocompleteData, initialAutocompleteData,
initialQueryBuilderFormValuesMap, initialQueryBuilderFormValuesMap,
listViewInitialLogQuery,
listViewInitialTraceQuery,
mapOfFormulaToFilters, mapOfFormulaToFilters,
mapOfQueryFilters, mapOfQueryFilters,
PANEL_TYPES, PANEL_TYPES,
@@ -23,10 +25,6 @@ import {
metricsUnknownSpaceAggregateOperatorOptions, metricsUnknownSpaceAggregateOperatorOptions,
metricsUnknownTimeAggregateOperatorOptions, metricsUnknownTimeAggregateOperatorOptions,
} from 'constants/queryBuilderOperators'; } from 'constants/queryBuilderOperators';
import {
listViewInitialLogQuery,
listViewInitialTraceQuery,
} from 'container/DashboardContainer/ComponentsSlider/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getMetricsOperatorsByAttributeType } from 'lib/newQueryBuilder/getMetricsOperatorsByAttributeType'; import { getMetricsOperatorsByAttributeType } from 'lib/newQueryBuilder/getMetricsOperatorsByAttributeType';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType'; import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';

View File

@@ -0,0 +1,143 @@
import { Route } from 'react-router-dom';
import * as getDashboardModule from 'api/v1/dashboards/id/get';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import DashboardWidget from '../index';
const DASHBOARD_ID = 'dash-1';
const WIDGET_ID = 'widget-abc';
const mockDashboardResponse = {
status: 'success',
data: {
id: DASHBOARD_ID,
createdAt: '2024-01-01T00:00:00Z',
createdBy: 'test',
updatedAt: '2024-01-01T00:00:00Z',
updatedBy: 'test',
isLocked: false,
data: {
collapsableRowsMigrated: true,
description: '',
name: '',
panelMap: {},
tags: [],
title: 'Test Dashboard',
uploadedGrafana: false,
uuid: '',
version: '',
variables: {},
widgets: [],
layout: [],
},
},
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('container/NewWidget', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="new-widget">NewWidget</div>,
}));
// Wrap component in a Route so useParams can resolve dashboardId.
// Query params are passed via the URL so useUrlQuery (react-router) can read them.
function renderAtRoute(
queryState: Record<string, string | null> = {},
): ReturnType<typeof render> {
const params = new URLSearchParams();
Object.entries(queryState).forEach(([k, v]) => {
if (v !== null) {
params.set(k, v);
}
});
const search = params.toString() ? `?${params.toString()}` : '';
return render(
<Route path="/dashboard/:dashboardId/new">
<DashboardWidget />
</Route>,
undefined,
{ initialRoute: `/dashboard/${DASHBOARD_ID}/new${search}` },
);
}
beforeEach(() => {
mockSafeNavigate.mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('DashboardWidget', () => {
it('redirects to dashboard when widgetId is missing', async () => {
renderAtRoute({ graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
});
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
});
it('redirects to dashboard when graphType is missing', async () => {
renderAtRoute({ widgetId: WIDGET_ID });
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
});
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
});
it('shows spinner while dashboard is loading', () => {
// Spy instead of MSW delay('infinite') to avoid leaving an open network handle.
jest
.spyOn(getDashboardModule, 'default')
.mockReturnValue(new Promise(() => {}));
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
expect(screen.getByRole('img', { name: 'loading' })).toBeInTheDocument();
});
it('shows error message when dashboard fetch fails', async () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});
it('renders NewWidget when dashboard loads successfully', async () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(mockDashboardResponse)),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(screen.getByTestId('new-widget')).toBeInTheDocument();
});
});
});

View File

@@ -1,29 +1,34 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { generatePath, useParams } from 'react-router-dom'; import { generatePath, useParams } from 'react-router-dom';
import { Card, Typography } from 'antd'; import { Card, Typography } from 'antd';
import getDashboard from 'api/v1/dashboards/id/get'; import getDashboard from 'api/v1/dashboards/id/get';
import Spinner from 'components/Spinner'; import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api'; import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime'; import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import NewWidget from 'container/NewWidget'; import NewWidget from 'container/NewWidget';
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils'; import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate'; import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { parseAsStringEnum, useQueryState } from 'nuqs'; import useUrlQuery from 'hooks/useUrlQuery';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore'; import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { Dashboard } from 'types/api/dashboard/getAll';
function DashboardWidget(): JSX.Element | null { function DashboardWidget(): JSX.Element | null {
const { dashboardId } = useParams<{ const { dashboardId } = useParams<{
dashboardId: string; dashboardId: string;
}>(); }>();
const [widgetId] = useQueryState('widgetId'); const query = useUrlQuery();
const [graphType] = useQueryState( const { graphType, widgetId } = useMemo(() => {
'graphType', return {
parseAsStringEnum<PANEL_TYPES>(Object.values(PANEL_TYPES)), graphType: query.get(QueryParams.graphType) as PANEL_TYPES,
); widgetId: query.get(QueryParams.widgetId),
};
}, [query]);
const { safeNavigate } = useSafeNavigate(); const { safeNavigate } = useSafeNavigate();
@@ -57,8 +62,15 @@ function DashboardWidgetInternal({
widgetId: string; widgetId: string;
graphType: PANEL_TYPES; graphType: PANEL_TYPES;
}): JSX.Element | null { }): JSX.Element | null {
const [selectedDashboard, setSelectedDashboard] = useState<
Dashboard | undefined
>(undefined);
const { transformDashboardVariables } = useTransformDashboardVariables(
dashboardId,
);
const { const {
data: dashboardResponse,
isFetching: isFetchingDashboardResponse, isFetching: isFetchingDashboardResponse,
isError: isErrorDashboardResponse, isError: isErrorDashboardResponse,
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], { } = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
@@ -70,17 +82,15 @@ function DashboardWidgetInternal({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
cacheTime: DASHBOARD_CACHE_TIME, cacheTime: DASHBOARD_CACHE_TIME,
onSuccess: (response) => { onSuccess: (response) => {
const updatedDashboardData = transformDashboardVariables(response.data);
setSelectedDashboard(updatedDashboardData);
setDashboardVariablesStore({ setDashboardVariablesStore({
dashboardId, dashboardId,
variables: response.data.data.variables, variables: updatedDashboardData.data.variables,
}); });
}, },
}); });
const selectedDashboard = useMemo(() => dashboardResponse?.data, [
dashboardResponse?.data,
]);
if (isFetchingDashboardResponse) { if (isFetchingDashboardResponse) {
return <Spinner tip="Loading.." />; return <Spinner tip="Loading.." />;
} }

View File

@@ -17,21 +17,18 @@ import { useDispatch, useSelector } from 'react-redux';
import { Modal } from 'antd'; import { Modal } from 'antd';
import getDashboard from 'api/v1/dashboards/id/get'; import getDashboard from 'api/v1/dashboards/id/get';
import locked from 'api/v1/dashboards/id/lock'; import locked from 'api/v1/dashboards/id/lock';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage'; import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import useTabVisibility from 'hooks/useTabFocus'; import useTabVisibility from 'hooks/useTabFocus';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout'; import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import { getMinMaxForSelectedTime } from 'lib/getMinMax'; import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { defaultTo, isEmpty } from 'lodash-es'; import { defaultTo } from 'lodash-es';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import isUndefined from 'lodash-es/isUndefined'; import isUndefined from 'lodash-es/isUndefined';
import omitBy from 'lodash-es/omitBy'; import omitBy from 'lodash-es/omitBy';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables'; import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
import { useErrorModal } from 'providers/ErrorModalProvider'; import { useErrorModal } from 'providers/ErrorModalProvider';
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@@ -39,10 +36,9 @@ import { AppState } from 'store/reducers';
import AppActions from 'types/actions'; import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime'; import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { SuccessResponseV2 } from 'types/api'; import { SuccessResponseV2 } from 'types/api';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll'; import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { import {
DASHBOARD_CACHE_TIME, DASHBOARD_CACHE_TIME,
@@ -57,9 +53,7 @@ import { IDashboardContext, WidgetColumnWidths } from './types';
import { sortLayout } from './util'; import { sortLayout } from './util';
export const DashboardContext = createContext<IDashboardContext>({ export const DashboardContext = createContext<IDashboardContext>({
isDashboardSliderOpen: false,
isDashboardLocked: false, isDashboardLocked: false,
handleToggleDashboardSlider: () => {},
handleDashboardLockToggle: () => {}, handleDashboardLockToggle: () => {},
dashboardResponse: {} as UseQueryResult< dashboardResponse: {} as UseQueryResult<
SuccessResponseV2<Dashboard>, SuccessResponseV2<Dashboard>,
@@ -86,8 +80,6 @@ export function DashboardProvider({
children, children,
dashboardId, dashboardId,
}: PropsWithChildren<{ dashboardId: string }>): JSX.Element { }: PropsWithChildren<{ dashboardId: string }>): JSX.Element {
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false); const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false);
const [ const [
@@ -137,9 +129,10 @@ export function DashboardProvider({
const { const {
currentDashboard, currentDashboard,
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId); getUrlVariables,
updateUrlVariable,
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl(); transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
const modalRef = useRef<any>(null); const modalRef = useRef<any>(null);
@@ -151,99 +144,6 @@ export function DashboardProvider({
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false); const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (!isEmpty(urlVariable)) {
if (urlVariable === ALL_SELECTED_VALUE) {
updatedVariable = {
...updatedVariable,
allSelected: true,
};
} else {
// Normalize URL value to match variable's multiSelect configuration
const normalizedValue = normalizeUrlValueForVariable(
urlVariable,
variableData,
);
updatedVariable = {
...updatedVariable,
selectedValue: normalizedValue,
// Only set allSelected to false if showALLOption is available
...(updatedVariable?.showALLOption && { allSelected: false }),
};
}
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
}
return updatedData;
};
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
// eslint-disable-next-line sonarjs/cognitive-complexity
const transformDashboardVariables = (data: Dashboard): Dashboard => {
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
for (const key in variables) {
// eslint-disable-next-line no-prototype-builtins
if (variables.hasOwnProperty(key)) {
const variable: IDashboardVariable = variables[key];
// Check if 'order' property doesn't exist or is undefined
if (variable.order === undefined) {
// Find a unique order starting from 0
let order = 0;
while (existingOrders.has(order)) {
order += 1;
}
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {
variable.id = generateUUID();
}
}
}
return clonedDashboardData;
}
return data;
};
const dashboardResponse = useQuery( const dashboardResponse = useQuery(
[ [
REACT_QUERY_KEY.DASHBOARD_BY_ID, REACT_QUERY_KEY.DASHBOARD_BY_ID,
@@ -274,13 +174,14 @@ export function DashboardProvider({
}, },
onSuccess: (data: SuccessResponseV2<Dashboard>) => { onSuccess: (data: SuccessResponseV2<Dashboard>) => {
// if the url variable is not set for any variable, set it to the default value const updatedDashboardData = transformDashboardVariables(data?.data);
const variables = data?.data?.data?.variables;
// initialize URL variables after dashboard state is set to avoid race conditions
const variables = updatedDashboardData?.data?.variables;
if (variables) { if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable); initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
} }
const updatedDashboardData = transformDashboardVariables(data?.data);
const updatedDate = dayjs(updatedDashboardData?.updatedAt); const updatedDate = dayjs(updatedDashboardData?.updatedAt);
setIsDashboardLocked(updatedDashboardData?.locked || false); setIsDashboardLocked(updatedDashboardData?.locked || false);
@@ -383,13 +284,8 @@ export function DashboardProvider({
} }
}, [isVisible]); }, [isVisible]);
const handleToggleDashboardSlider = (value: boolean): void => {
setIsDashboardSlider(value);
};
const { mutate: lockDashboard } = useMutation(locked, { const { mutate: lockDashboard } = useMutation(locked, {
onSuccess: (_, props) => { onSuccess: (_, props) => {
setIsDashboardSlider(false);
setIsDashboardLocked(props.lock); setIsDashboardLocked(props.lock);
}, },
onError: (error) => { onError: (error) => {
@@ -414,9 +310,7 @@ export function DashboardProvider({
const value: IDashboardContext = useMemo( const value: IDashboardContext = useMemo(
() => ({ () => ({
isDashboardSliderOpen,
isDashboardLocked, isDashboardLocked,
handleToggleDashboardSlider,
handleDashboardLockToggle, handleDashboardLockToggle,
dashboardResponse, dashboardResponse,
selectedDashboard, selectedDashboard,
@@ -436,7 +330,6 @@ export function DashboardProvider({
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
isDashboardSliderOpen,
isDashboardLocked, isDashboardLocked,
dashboardResponse, dashboardResponse,
selectedDashboard, selectedDashboard,

View File

@@ -381,6 +381,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
multiSelect: false, multiSelect: false,
allSelected: false, allSelected: false,
showALLOption: true, showALLOption: true,
order: 0,
}, },
services: { services: {
id: 'svc-id', id: 'svc-id',
@@ -388,6 +389,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
multiSelect: true, multiSelect: true,
allSelected: false, allSelected: false,
showALLOption: true, showALLOption: true,
order: 1,
}, },
}, },
mockGetUrlVariables, mockGetUrlVariables,

View File

@@ -0,0 +1,18 @@
import { create } from 'zustand';
interface IPanelTypeSelectionModalState {
isPanelTypeSelectionModalOpen: boolean;
setIsPanelTypeSelectionModalOpen: (isOpen: boolean) => void;
}
/**
* This helper is used for selecting the panel type when creating a new panel in the dashboard.
* It uses Zustand for state management to keep track of whether the panel type selection modal is open or closed.
*/
export const usePanelTypeSelectionModalStore = create<IPanelTypeSelectionModalState>(
(set) => ({
isPanelTypeSelectionModalOpen: false,
setIsPanelTypeSelectionModalOpen: (isOpen): void =>
set({ isPanelTypeSelectionModalOpen: isOpen }),
}),
);

View File

@@ -9,9 +9,7 @@ export type WidgetColumnWidths = {
}; };
export interface IDashboardContext { export interface IDashboardContext {
isDashboardSliderOpen: boolean;
isDashboardLocked: boolean; isDashboardLocked: boolean;
handleToggleDashboardSlider: (value: boolean) => void;
handleDashboardLockToggle: (value: boolean) => void; handleDashboardLockToggle: (value: boolean) => void;
dashboardResponse: UseQueryResult<SuccessResponseV2<Dashboard>, unknown>; dashboardResponse: UseQueryResult<SuccessResponseV2<Dashboard>, unknown>;
selectedDashboard: Dashboard | undefined; selectedDashboard: Dashboard | undefined;

2
go.mod
View File

@@ -11,7 +11,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.2 github.com/SigNoz/signoz-otel-collector v0.144.2
github.com/antlr4-go/antlr/v4 v4.13.1 github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3 github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0 github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0 github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0 github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -106,6 +105,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect

View File

@@ -78,7 +78,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
// add the paths that are not promoted but have indexes // add the paths that are not promoted but have indexes
for path, indexes := range aggr { for path, indexes := range aggr {
path := strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix) path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
path = telemetrytypes.BodyJSONStringSearchPrefix + path path = telemetrytypes.BodyJSONStringSearchPrefix + path
response = append(response, promotetypes.PromotePath{ response = append(response, promotetypes.PromotePath{
Path: path, Path: path,
@@ -163,7 +163,7 @@ func (m *module) PromoteAndIndexPaths(
} }
} }
if len(it.Indexes) > 0 { if len(it.Indexes) > 0 {
parentColumn := telemetrylogs.LogsV2BodyJSONColumn parentColumn := telemetrylogs.LogsV2BodyV2Column
// if the path is already promoted or is being promoted, add it to the promoted column // if the path is already promoted or is being promoted, add it to the promoted column
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote { if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn parentColumn = telemetrylogs.LogsV2BodyPromotedColumn

View File

@@ -10,13 +10,11 @@ import (
"github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes" "github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes" "github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
) )
type builderQuery[T any] struct { type builderQuery[T any] struct {
@@ -262,40 +260,6 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
return nil, err return nil, err
} }
// merge body_json and promoted into body
if q.spec.Signal == telemetrytypes.SignalLogs {
switch typedPayload := payload.(type) {
case *qbtypes.RawData:
for _, rr := range typedPayload.Rows {
seeder := func() error {
body, ok := rr.Data[telemetrylogs.LogsV2BodyJSONColumn].(map[string]any)
if !ok {
return nil
}
promoted, ok := rr.Data[telemetrylogs.LogsV2BodyPromotedColumn].(map[string]any)
if !ok {
return nil
}
seed(promoted, body)
str, err := sonic.MarshalString(body)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal body")
}
rr.Data["body"] = str
return nil
}
err := seeder()
if err != nil {
return nil, err
}
delete(rr.Data, telemetrylogs.LogsV2BodyJSONColumn)
delete(rr.Data, telemetrylogs.LogsV2BodyPromotedColumn)
}
payload = typedPayload
}
}
return &qbtypes.Result{ return &qbtypes.Result{
Type: q.kind, Type: q.kind,
Value: payload, Value: payload,
@@ -423,18 +387,3 @@ func decodeCursor(cur string) (int64, error) {
} }
return strconv.ParseInt(string(b), 10, 64) return strconv.ParseInt(string(b), 10, 64)
} }
func seed(promoted map[string]any, body map[string]any) {
for key, fromValue := range promoted {
if toValue, ok := body[key]; !ok {
body[key] = fromValue
} else {
if fromValue, ok := fromValue.(map[string]any); ok {
if toValue, ok := toValue.(map[string]any); ok {
seed(fromValue, toValue)
body[key] = toValue
}
}
}
}
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/ClickHouse/clickhouse-go/v2/lib/driver" "github.com/ClickHouse/clickhouse-go/v2/lib/driver"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
) )
var ( var (
@@ -394,17 +393,11 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any // de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface() val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: normalize into String value
// Post-process JSON columns: normalize into structured values
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") { if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) { switch x := val.(type) {
case []byte: case []byte:
if len(x) > 0 { val = string(x)
var v any
if err := sonic.Unmarshal(x, &v); err == nil {
val = v
}
}
default: default:
// already a structured type (map[string]any, []any, etc.) // already a structured type (map[string]any, []any, etc.)
} }

View File

@@ -219,7 +219,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
// we don't have a toBoolOrNull in ClickHouse, so we need to convert the bool to a string // we don't have a toBoolOrNull in ClickHouse, so we need to convert the bool to a string
value = fmt.Sprintf("%t", v) value = fmt.Sprintf("%t", v)
} }
case telemetrytypes.FieldDataTypeInt64, case telemetrytypes.FieldDataTypeInt64,
telemetrytypes.FieldDataTypeArrayInt64, telemetrytypes.FieldDataTypeArrayInt64,
telemetrytypes.FieldDataTypeNumber, telemetrytypes.FieldDataTypeNumber,

View File

@@ -313,37 +313,30 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
return "" return ""
} }
child := ctx.GetChild(0) child := ctx.GetChild(0)
var searchText string
if keyCtx, ok := child.(*grammar.KeyContext); ok { if keyCtx, ok := child.(*grammar.KeyContext); ok {
// create a full text search condition on the body field // create a full text search condition on the body field
searchText = keyCtx.GetText()
keyText := keyCtx.GetText()
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
}
return cond
} else if valCtx, ok := child.(*grammar.ValueContext); ok { } else if valCtx, ok := child.(*grammar.ValueContext); ok {
var text string
if valCtx.QUOTED_TEXT() != nil { if valCtx.QUOTED_TEXT() != nil {
text = trimQuotes(valCtx.QUOTED_TEXT().GetText()) searchText = trimQuotes(valCtx.QUOTED_TEXT().GetText())
} else if valCtx.NUMBER() != nil { } else if valCtx.NUMBER() != nil {
text = valCtx.NUMBER().GetText() searchText = valCtx.NUMBER().GetText()
} else if valCtx.BOOL() != nil { } else if valCtx.BOOL() != nil {
text = valCtx.BOOL().GetText() searchText = valCtx.BOOL().GetText()
} else if valCtx.KEY() != nil { } else if valCtx.KEY() != nil {
text = valCtx.KEY().GetText() searchText = valCtx.KEY().GetText()
} else { } else {
v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText())) v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText()))
return "" return ""
} }
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
}
return cond
} }
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
}
return cond
} }
return "" // Should not happen with valid input return "" // Should not happen with valid input
@@ -383,6 +376,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
for _, key := range keys { for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder, v.startNs, v.endNs) condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder, v.startNs, v.endNs)
if err != nil { if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
return "" return ""
} }
conds = append(conds, condition) conds = append(conds, condition)
@@ -648,7 +642,6 @@ func (v *filterExpressionVisitor) VisitValueList(ctx *grammar.ValueListContext)
// VisitFullText handles standalone quoted strings for full-text search // VisitFullText handles standalone quoted strings for full-text search
func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any { func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
if v.skipFullTextFilter { if v.skipFullTextFilter {
return "" return ""
} }
@@ -670,6 +663,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error())) v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return "" return ""
} }
return cond return cond
} }

View File

@@ -3,14 +3,12 @@ package telemetrylogs
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator" schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder" "github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"golang.org/x/exp/maps"
"github.com/huandu/go-sqlbuilder" "github.com/huandu/go-sqlbuilder"
) )
@@ -35,7 +33,7 @@ func (c *conditionBuilder) conditionFor(
return "", err return "", err
} }
if column.Type.GetType() == schema.ColumnTypeEnumJSON && querybuilder.BodyJSONQueryEnabled { if column.Type.GetType() == schema.ColumnTypeEnumJSON && querybuilder.BodyJSONQueryEnabled && key.Name != messageSubField {
valueType, value := InferDataType(value, operator, key) valueType, value := InferDataType(value, operator, key)
cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb) cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb)
if err != nil { if err != nil {
@@ -54,14 +52,14 @@ func (c *conditionBuilder) conditionFor(
} }
// Check if this is a body JSON search - either by FieldContext // Check if this is a body JSON search - either by FieldContext
if key.FieldContext == telemetrytypes.FieldContextBody { if key.FieldContext == telemetrytypes.FieldContextBody && !querybuilder.BodyJSONQueryEnabled {
tblFieldName, value = GetBodyJSONKey(ctx, key, operator, value) tblFieldName, value = GetBodyJSONKey(ctx, key, operator, value)
} }
tblFieldName, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, tblFieldName, operator) tblFieldName, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, tblFieldName, operator)
// make use of case insensitive index for body // make use of case insensitive index for body
if tblFieldName == "body" { if tblFieldName == "body" || tblFieldName == messageSubColumn {
switch operator { switch operator {
case qbtypes.FilterOperatorLike: case qbtypes.FilterOperatorLike:
return sb.ILike(tblFieldName, value), nil return sb.ILike(tblFieldName, value), nil
@@ -108,7 +106,6 @@ func (c *conditionBuilder) conditionFor(
return sb.ILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil return sb.ILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorNotContains: case qbtypes.FilterOperatorNotContains:
return sb.NotILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil return sb.NotILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorRegexp: case qbtypes.FilterOperatorRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs // Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder) // Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)
@@ -178,9 +175,8 @@ func (c *conditionBuilder) conditionFor(
case schema.ColumnTypeEnumJSON: case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists { if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(tblFieldName), nil return sb.IsNotNull(tblFieldName), nil
} else {
return sb.IsNull(tblFieldName), nil
} }
return sb.IsNull(tblFieldName), nil
case schema.ColumnTypeEnumLowCardinality: case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() { switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString: case schema.ColumnTypeEnumString:
@@ -247,19 +243,30 @@ func (c *conditionBuilder) ConditionFor(
return "", err return "", err
} }
if !(key.FieldContext == telemetrytypes.FieldContextBody && querybuilder.BodyJSONQueryEnabled) && operator.AddDefaultExistsFilter() { // Skip adding exists filter for intrinsic fields i.e. Table level log context fields
// skip adding exists filter for intrinsic fields buildExistCondition := operator.AddDefaultExistsFilter()
// with an exception for body json search switch key.FieldContext {
field, _ := c.fm.FieldFor(ctx, key) case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextScope:
if slices.Contains(maps.Keys(IntrinsicFields), field) && key.FieldContext != telemetrytypes.FieldContextBody { // pass; No need to build exist condition for top level columns
// immidiately return
return condition, nil
case telemetrytypes.FieldContextResource, telemetrytypes.FieldContextAttribute:
// build exist condition for resource and attribute fields based on filter operator
case telemetrytypes.FieldContextBody:
// Querying JSON fields already account for Nullability of fields
// so additional exists checks are not needed
if querybuilder.BodyJSONQueryEnabled {
return condition, nil return condition, nil
} }
}
if buildExistCondition {
existsCondition, err := c.conditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb) existsCondition, err := c.conditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb)
if err != nil { if err != nil {
return "", err return "", err
} }
return sb.And(condition, existsCondition), nil return sb.And(condition, existsCondition), nil
} }
return condition, nil return condition, nil
} }

View File

@@ -127,7 +127,8 @@ func TestConditionFor(t *testing.T) {
{ {
name: "Contains operator - body", name: "Contains operator - body",
key: telemetrytypes.TelemetryFieldKey{ key: telemetrytypes.TelemetryFieldKey{
Name: "body", Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
}, },
operator: qbtypes.FilterOperatorContains, operator: qbtypes.FilterOperatorContains,
value: 521509198310, value: 521509198310,

View File

@@ -1,7 +1,10 @@
package telemetrylogs package telemetrylogs
import ( import (
"fmt"
"github.com/SigNoz/signoz-otel-collector/constants" "github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
) )
@@ -17,7 +20,7 @@ const (
LogsV2TimestampColumn = "timestamp" LogsV2TimestampColumn = "timestamp"
LogsV2ObservedTimestampColumn = "observed_timestamp" LogsV2ObservedTimestampColumn = "observed_timestamp"
LogsV2BodyColumn = "body" LogsV2BodyColumn = "body"
LogsV2BodyJSONColumn = constants.BodyV2Column LogsV2BodyV2Column = constants.BodyV2Column
LogsV2BodyPromotedColumn = constants.BodyPromotedColumn LogsV2BodyPromotedColumn = constants.BodyPromotedColumn
LogsV2TraceIDColumn = "trace_id" LogsV2TraceIDColumn = "trace_id"
LogsV2SpanIDColumn = "span_id" LogsV2SpanIDColumn = "span_id"
@@ -34,8 +37,14 @@ const (
LogsV2ResourcesStringColumn = "resources_string" LogsV2ResourcesStringColumn = "resources_string"
LogsV2ScopeStringColumn = "scope_string" LogsV2ScopeStringColumn = "scope_string"
BodyJSONColumnPrefix = constants.BodyV2ColumnPrefix BodyV2ColumnPrefix = constants.BodyV2ColumnPrefix
BodyPromotedColumnPrefix = constants.BodyPromotedColumnPrefix BodyPromotedColumnPrefix = constants.BodyPromotedColumnPrefix
// messageSubColumn is the ClickHouse sub-column that body searches map to
// when BodyJSONQueryEnabled is true.
messageSubField = "message"
messageSubColumn = "body_v2.message"
bodySearchDefaultWarning = "body searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
) )
var ( var (
@@ -118,3 +127,11 @@ var (
}, },
} }
) )
func bodyAliasExpression() string {
if !querybuilder.BodyJSONQueryEnabled {
return LogsV2BodyColumn
}
return fmt.Sprintf("%s as body", LogsV2BodyV2Column)
}

View File

@@ -30,7 +30,8 @@ var (
"severity_text": {Name: "severity_text", Type: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}}, "severity_text": {Name: "severity_text", Type: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}},
"severity_number": {Name: "severity_number", Type: schema.ColumnTypeUInt8}, "severity_number": {Name: "severity_number", Type: schema.ColumnTypeUInt8},
"body": {Name: "body", Type: schema.ColumnTypeString}, "body": {Name: "body", Type: schema.ColumnTypeString},
LogsV2BodyJSONColumn: {Name: LogsV2BodyJSONColumn, Type: schema.JSONColumnType{ messageSubColumn: {Name: messageSubColumn, Type: schema.ColumnTypeString},
LogsV2BodyV2Column: {Name: LogsV2BodyV2Column, Type: schema.JSONColumnType{
MaxDynamicTypes: utils.ToPointer(uint(32)), MaxDynamicTypes: utils.ToPointer(uint(32)),
MaxDynamicPaths: utils.ToPointer(uint(0)), MaxDynamicPaths: utils.ToPointer(uint(0)),
}}, }},
@@ -88,21 +89,26 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
return logsV2Columns["attributes_bool"], nil return logsV2Columns["attributes_bool"], nil
} }
case telemetrytypes.FieldContextBody: case telemetrytypes.FieldContextBody:
// Body context is for JSON body fields // Body context is for JSON body fields. Use body_v2 if feature flag is enabled.
// Use body_json if feature flag is enabled
if querybuilder.BodyJSONQueryEnabled { if querybuilder.BodyJSONQueryEnabled {
return logsV2Columns[LogsV2BodyJSONColumn], nil if key.Name == messageSubField {
return logsV2Columns[messageSubColumn], nil
}
return logsV2Columns[LogsV2BodyV2Column], nil
} }
// Fall back to legacy body column // Fall back to legacy body column
return logsV2Columns["body"], nil return logsV2Columns["body"], nil
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified: case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
if key.Name == LogsV2BodyColumn && querybuilder.BodyJSONQueryEnabled {
return logsV2Columns[messageSubColumn], nil
}
col, ok := logsV2Columns[key.Name] col, ok := logsV2Columns[key.Name]
if !ok { if !ok {
// check if the key has body JSON search // check if the key has body JSON search
if strings.HasPrefix(key.Name, telemetrytypes.BodyJSONStringSearchPrefix) { if strings.HasPrefix(key.Name, telemetrytypes.BodyJSONStringSearchPrefix) {
// Use body_json if feature flag is enabled and we have a body condition builder // Use body_v2 if feature flag is enabled and we have a body condition builder
if querybuilder.BodyJSONQueryEnabled { if querybuilder.BodyJSONQueryEnabled {
return logsV2Columns[LogsV2BodyJSONColumn], nil return logsV2Columns[LogsV2BodyV2Column], nil
} }
// Fall back to legacy body column // Fall back to legacy body column
return logsV2Columns["body"], nil return logsV2Columns["body"], nil
@@ -138,6 +144,10 @@ func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.Telemetr
} }
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
case telemetrytypes.FieldContextBody: case telemetrytypes.FieldContextBody:
if key.Name == messageSubField {
return messageSubColumn, nil
}
if key.JSONDataType == nil { if key.JSONDataType == nil {
return "", qbtypes.ErrColumnNotFound return "", qbtypes.ErrColumnNotFound
} }
@@ -246,34 +256,37 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
node := plan[0] node := plan[0]
expr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue()) expr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
if key.Materialized { // TODO(Piyush): Promoted path logic commented out. Materialized now means type hint
if len(plan) < 2 { // promotion will be extracted from key field evolution
return "", errors.Newf(errors.TypeUnexpected, CodePromotedPlanMissing, // (direct sub-column access), not a promoted body_promoted.* column.
"plan length is less than 2 for promoted path: %s", key.Name) // if key.Materialized {
} // if len(plan) < 2 {
// return "", errors.Newf(errors.TypeUnexpected, CodePromotedPlanMissing,
// "plan length is less than 2 for promoted path: %s", key.Name)
// }
node := plan[1] // node := plan[1]
promotedExpr := fmt.Sprintf( // promotedExpr := fmt.Sprintf(
"dynamicElement(%s, '%s')", // "dynamicElement(%s, '%s')",
node.FieldPath(), // node.FieldPath(),
node.TerminalConfig.ElemType.StringValue(), // node.TerminalConfig.ElemType.StringValue(),
) // )
// dynamicElement returns NULL for scalar types or an empty array for array types. // // dynamicElement returns NULL for scalar types or an empty array for array types.
if node.TerminalConfig.ElemType.IsArray { // if node.TerminalConfig.ElemType.IsArray {
expr = fmt.Sprintf( // expr = fmt.Sprintf(
"if(length(%s) > 0, %s, %s)", // "if(length(%s) > 0, %s, %s)",
promotedExpr, // promotedExpr,
promotedExpr, // promotedExpr,
expr, // expr,
) // )
} else { // } else {
// promoted column first then body_json column // // promoted column first then body_json column
// TODO(Piyush): Change this in future for better performance // // TODO(Piyush): Change this in future for better performance
expr = fmt.Sprintf("coalesce(%s, %s)", promotedExpr, expr) // expr = fmt.Sprintf("coalesce(%s, %s)", promotedExpr, expr)
} // }
} // }
return expr, nil return expr, nil
} }

View File

@@ -30,7 +30,7 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
return &jsonConditionBuilder{key: key, valueType: telemetrytypes.MappingFieldDataTypeToJSONDataType[valueType]} return &jsonConditionBuilder{key: key, valueType: telemetrytypes.MappingFieldDataTypeToJSONDataType[valueType]}
} }
// BuildCondition builds the full WHERE condition for body_json JSON paths // BuildCondition builds the full WHERE condition for body_v2 JSON paths
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) { func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
conditions := []string{} conditions := []string{}
for _, node := range c.key.JSONPlan { for _, node := range c.key.JSONPlan {
@@ -40,6 +40,7 @@ func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperato
} }
conditions = append(conditions, condition) conditions = append(conditions, condition)
} }
return sb.Or(conditions...), nil return sb.Or(conditions...), nil
} }
@@ -288,9 +289,9 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
} }
return sb.NotIn(fieldExpr, values...), nil return sb.NotIn(fieldExpr, values...), nil
case qbtypes.FilterOperatorExists: case qbtypes.FilterOperatorExists:
return fmt.Sprintf("%s IS NOT NULL", fieldExpr), nil return sb.IsNotNull(fieldExpr), nil
case qbtypes.FilterOperatorNotExists: case qbtypes.FilterOperatorNotExists:
return fmt.Sprintf("%s IS NULL", fieldExpr), nil return sb.IsNull(fieldExpr), nil
// between and not between // between and not between
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween: case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any) values, ok := value.([]any)

File diff suppressed because one or more lines are too long

View File

@@ -65,7 +65,7 @@ func (b *logQueryStatementBuilder) Build(
start = querybuilder.ToNanoSecs(start) start = querybuilder.ToNanoSecs(start)
end = querybuilder.ToNanoSecs(end) end = querybuilder.ToNanoSecs(end)
keySelectors := getKeySelectors(query) keySelectors, warnings := getKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors) keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -76,20 +76,29 @@ func (b *logQueryStatementBuilder) Build(
// Create SQL builder // Create SQL builder
q := sqlbuilder.NewSelectBuilder() q := sqlbuilder.NewSelectBuilder()
var stmt *qbtypes.Statement
switch requestType { switch requestType {
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeRawStream: case qbtypes.RequestTypeRaw, qbtypes.RequestTypeRawStream:
return b.buildListQuery(ctx, q, query, start, end, keys, variables) stmt, err = b.buildListQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeTimeSeries: case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables) stmt, err = b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar: case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, q, query, start, end, keys, false, variables) stmt, err = b.buildScalarQuery(ctx, q, query, start, end, keys, false, variables)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
} }
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType) if err != nil {
return nil, err
}
stmt.Warnings = append(stmt.Warnings, warnings...)
return stmt, nil
} }
func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) []*telemetrytypes.FieldKeySelector { func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) ([]*telemetrytypes.FieldKeySelector, []string) {
var keySelectors []*telemetrytypes.FieldKeySelector var keySelectors []*telemetrytypes.FieldKeySelector
var warnings []string
for idx := range query.Aggregations { for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx] aggExpr := query.Aggregations[idx]
@@ -136,7 +145,19 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) []
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
} }
return keySelectors // When the new JSON body experience is enabled, warn the user if they use the bare
// "body" key in the filter — queries on plain "body" default to body.message:string.
// TODO(Piyush): Setup better for coming FTS support.
if querybuilder.BodyJSONQueryEnabled {
for _, sel := range keySelectors {
if sel.Name == LogsV2BodyColumn {
warnings = append(warnings, bodySearchDefaultWarning)
break
}
}
}
return keySelectors, warnings
} }
func (b *logQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.LogAggregation] { func (b *logQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.LogAggregation] {
@@ -203,7 +224,6 @@ func (b *logQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[stri
} }
func (b *logQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string { func (b *logQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
// First check if it matches with any intrinsic fields // First check if it matches with any intrinsic fields
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
if _, ok := IntrinsicFields[key.Name]; ok { if _, ok := IntrinsicFields[key.Name]; ok {
@@ -212,7 +232,6 @@ func (b *logQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldK
} }
return querybuilder.AdjustKey(key, keys, nil) return querybuilder.AdjustKey(key, keys, nil)
} }
// buildListQuery builds a query for list panel type // buildListQuery builds a query for list panel type
@@ -249,11 +268,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
sb.SelectMore(LogsV2SeverityNumberColumn) sb.SelectMore(LogsV2SeverityNumberColumn)
sb.SelectMore(LogsV2ScopeNameColumn) sb.SelectMore(LogsV2ScopeNameColumn)
sb.SelectMore(LogsV2ScopeVersionColumn) sb.SelectMore(LogsV2ScopeVersionColumn)
sb.SelectMore(LogsV2BodyColumn) sb.SelectMore(bodyAliasExpression())
if querybuilder.BodyJSONQueryEnabled {
sb.SelectMore(LogsV2BodyJSONColumn)
sb.SelectMore(LogsV2BodyPromotedColumn)
}
sb.SelectMore(LogsV2AttributesStringColumn) sb.SelectMore(LogsV2AttributesStringColumn)
sb.SelectMore(LogsV2AttributesNumberColumn) sb.SelectMore(LogsV2AttributesNumberColumn)
sb.SelectMore(LogsV2AttributesBoolColumn) sb.SelectMore(LogsV2AttributesBoolColumn)

View File

@@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder" "github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/querybuilder/resourcefilter" "github.com/SigNoz/signoz/pkg/querybuilder/resourcefilter"
@@ -886,3 +887,246 @@ func TestAdjustKey(t *testing.T) {
}) })
} }
} }
func TestStmtBuilderBodyField(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
enableBodyJSONQuery bool
expected qbtypes.Statement
expectedErr error
}{
{
name: "body_exists",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body Exists"},
Limit: 10,
},
enableBodyJSONQuery: true,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND body_v2.message <> ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{bodySearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "body_exists_disabled",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body Exists"},
Limit: 10,
},
enableBodyJSONQuery: false,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND body <> ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "body_empty",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body == ''"},
Limit: 10,
},
enableBodyJSONQuery: true,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND body_v2.message = ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{bodySearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "body_empty_disabled",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body == ''"},
Limit: 10,
},
enableBodyJSONQuery: false,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND body = ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "body_contains",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'error'"},
Limit: 10,
},
enableBodyJSONQuery: true,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND LOWER(body_v2.message) LIKE LOWER(?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{bodySearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "body_contains_disabled",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'error'"},
Limit: 10,
},
enableBodyJSONQuery: false,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND LOWER(body) LIKE LOWER(?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
enable, disable := jsonQueryTestUtil(t)
defer disable()
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.enableBodyJSONQuery {
enable()
} else {
disable()
}
// build the key map after enabling/disabling body JSON query
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
for _, field := range IntrinsicFields {
f := field
mockMetadataStore.KeysMap[field.Name] = append(mockMetadataStore.KeysMap[field.Name], &f)
}
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
resourceFilterStmtBuilder := resourceFilterStmtBuilder()
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
resourceFilterStmtBuilder,
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
require.Contains(t, err.Error(), c.expectedErr.Error())
} else {
if err != nil {
_, _, _, _, _, add := errors.Unwrapb(err)
t.Logf("error additionals: %v", add)
}
require.NoError(t, err)
require.Equal(t, c.expected.Query, q.Query)
require.Equal(t, c.expected.Args, q.Args)
require.Equal(t, c.expected.Warnings, q.Warnings)
}
})
}
}
func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
enableBodyJSONQuery bool
expected qbtypes.Statement
expectedErr error
}{
{
name: "body_contains",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "'error'"},
Limit: 10,
},
enableBodyJSONQuery: true,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "body_contains_disabled",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "'error'"},
Limit: 10,
},
enableBodyJSONQuery: false,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
enable, disable := jsonQueryTestUtil(t)
defer disable()
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.enableBodyJSONQuery {
enable()
} else {
disable()
}
// build the key map after enabling/disabling body JSON query
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
for _, field := range IntrinsicFields {
f := field
mockMetadataStore.KeysMap[field.Name] = append(mockMetadataStore.KeysMap[field.Name], &f)
}
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
resourceFilterStmtBuilder := resourceFilterStmtBuilder()
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
resourceFilterStmtBuilder,
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
require.Contains(t, err.Error(), c.expectedErr.Error())
} else {
if err != nil {
_, _, _, _, _, add := errors.Unwrapb(err)
t.Logf("error additionals: %v", add)
}
require.NoError(t, err)
require.Equal(t, c.expected.Query, q.Query)
require.Equal(t, c.expected.Args, q.Args)
require.Equal(t, c.expected.Warnings, q.Warnings)
}
})
}
}

View File

@@ -27,13 +27,6 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
FieldDataType: telemetrytypes.FieldDataTypeString, FieldDataType: telemetrytypes.FieldDataTypeString,
}, },
}, },
"body": {
{
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"http.status_code": { "http.status_code": {
{ {
Name: "http.status_code", Name: "http.status_code",
@@ -938,6 +931,13 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
Materialized: true, Materialized: true,
}, },
}, },
"body": {
{
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
} }
for _, keys := range keysMap { for _, keys := range keysMap {
@@ -945,6 +945,7 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
key.Signal = telemetrytypes.SignalLogs key.Signal = telemetrytypes.SignalLogs
} }
} }
return keysMap return keysMap
} }

View File

@@ -54,6 +54,7 @@ func (t *telemetryMetaStore) fetchBodyJSONPaths(ctx context.Context,
instrumentationtypes.CodeNamespace: "metadata", instrumentationtypes.CodeNamespace: "metadata",
instrumentationtypes.CodeFunctionName: "fetchBodyJSONPaths", instrumentationtypes.CodeFunctionName: "fetchBodyJSONPaths",
}) })
query, args, limit := buildGetBodyJSONPathsQuery(fieldKeySelectors) query, args, limit := buildGetBodyJSONPathsQuery(fieldKeySelectors)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...) rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil { if err != nil {
@@ -184,7 +185,6 @@ func buildGetBodyJSONPathsQuery(fieldKeySelectors []*telemetrytypes.FieldKeySele
limit += fieldKeySelector.Limit limit += fieldKeySelector.Limit
} }
sb.Where(sb.Or(orClauses...)) sb.Where(sb.Or(orClauses...))
// Group by path to get unique paths with aggregated types // Group by path to get unique paths with aggregated types
sb.GroupBy("path") sb.GroupBy("path")
@@ -319,7 +319,7 @@ func (t *telemetryMetaStore) ListJSONValues(ctx context.Context, path string, li
if promoted { if promoted {
path = telemetrylogs.BodyPromotedColumnPrefix + path path = telemetrylogs.BodyPromotedColumnPrefix + path
} else { } else {
path = telemetrylogs.BodyJSONColumnPrefix + path path = telemetrylogs.BodyV2ColumnPrefix + path
} }
from := fmt.Sprintf("%s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName) from := fmt.Sprintf("%s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName)
@@ -522,7 +522,7 @@ func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...stri
// TODO(Piyush): Remove this function // TODO(Piyush): Remove this function
func CleanPathPrefixes(path string) string { func CleanPathPrefixes(path string) string {
path = strings.TrimPrefix(path, telemetrytypes.BodyJSONStringSearchPrefix) path = strings.TrimPrefix(path, telemetrytypes.BodyJSONStringSearchPrefix)
path = strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix) path = strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
path = strings.TrimPrefix(path, telemetrylogs.BodyPromotedColumnPrefix) path = strings.TrimPrefix(path, telemetrylogs.BodyPromotedColumnPrefix)
return path return path
} }

View File

@@ -102,7 +102,7 @@ func NewTelemetryMetaStore(
jsonColumnMetadata: map[telemetrytypes.Signal]map[telemetrytypes.FieldContext]telemetrytypes.JSONColumnMetadata{ jsonColumnMetadata: map[telemetrytypes.Signal]map[telemetrytypes.FieldContext]telemetrytypes.JSONColumnMetadata{
telemetrytypes.SignalLogs: { telemetrytypes.SignalLogs: {
telemetrytypes.FieldContextBody: telemetrytypes.JSONColumnMetadata{ telemetrytypes.FieldContextBody: telemetrytypes.JSONColumnMetadata{
BaseColumn: telemetrylogs.LogsV2BodyJSONColumn, BaseColumn: telemetrylogs.LogsV2BodyV2Column,
PromotedColumn: telemetrylogs.LogsV2BodyPromotedColumn, PromotedColumn: telemetrylogs.LogsV2BodyPromotedColumn,
}, },
}, },

View File

@@ -40,7 +40,7 @@ type JSONAccessNode struct {
// Node information // Node information
Name string Name string
IsTerminal bool IsTerminal bool
isRoot bool // marked true for only body_json and body_json_promoted isRoot bool // marked true for only body_v2 and body_promoted
// Precomputed type information (single source of truth) // Precomputed type information (single source of truth)
AvailableTypes []JSONDataType AvailableTypes []JSONDataType

View File

@@ -1,12 +1,19 @@
package telemetrytypes package telemetrytypes
import ( import (
"fmt"
"testing" "testing"
otelconstants "github.com/SigNoz/signoz-otel-collector/constants"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const (
bodyV2Column = otelconstants.BodyV2Column
bodyPromotedColumn = otelconstants.BodyPromotedColumn
)
// ============================================================================ // ============================================================================
// Helper Functions for Test Data Creation // Helper Functions for Test Data Creation
// ============================================================================ // ============================================================================
@@ -109,8 +116,8 @@ func TestNode_Alias(t *testing.T) {
}{ }{
{ {
name: "Root node returns name as-is", name: "Root node returns name as-is",
node: NewRootJSONAccessNode("body_json", 32, 0), node: NewRootJSONAccessNode(bodyV2Column, 32, 0),
expected: "body_json", expected: bodyV2Column,
}, },
{ {
name: "Node without parent returns backticked name", name: "Node without parent returns backticked name",
@@ -124,9 +131,9 @@ func TestNode_Alias(t *testing.T) {
name: "Node with root parent uses dot separator", name: "Node with root parent uses dot separator",
node: &JSONAccessNode{ node: &JSONAccessNode{
Name: "age", Name: "age",
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
expected: "`" + "body_json" + ".age`", expected: "`" + bodyV2Column + ".age`",
}, },
{ {
name: "Node with non-root parent uses array separator", name: "Node with non-root parent uses array separator",
@@ -134,10 +141,10 @@ func TestNode_Alias(t *testing.T) {
Name: "name", Name: "name",
Parent: &JSONAccessNode{ Parent: &JSONAccessNode{
Name: "education", Name: "education",
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
}, },
expected: "`" + "body_json" + ".education[].name`", expected: "`" + bodyV2Column + ".education[].name`",
}, },
{ {
name: "Nested array path with multiple levels", name: "Nested array path with multiple levels",
@@ -147,11 +154,11 @@ func TestNode_Alias(t *testing.T) {
Name: "awards", Name: "awards",
Parent: &JSONAccessNode{ Parent: &JSONAccessNode{
Name: "education", Name: "education",
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
}, },
}, },
expected: "`" + "body_json" + ".education[].awards[].type`", expected: "`" + bodyV2Column + ".education[].awards[].type`",
}, },
} }
@@ -173,18 +180,18 @@ func TestNode_FieldPath(t *testing.T) {
name: "Simple field path from root", name: "Simple field path from root",
node: &JSONAccessNode{ node: &JSONAccessNode{
Name: "user", Name: "user",
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
// FieldPath() always wraps the field name in backticks // FieldPath() always wraps the field name in backticks
expected: "body_json" + ".`user`", expected: bodyV2Column + ".`user`",
}, },
{ {
name: "Field path with backtick-required key", name: "Field path with backtick-required key",
node: &JSONAccessNode{ node: &JSONAccessNode{
Name: "user-name", // requires backtick Name: "user-name", // requires backtick
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
expected: "body_json" + ".`user-name`", expected: bodyV2Column + ".`user-name`",
}, },
{ {
name: "Nested field path", name: "Nested field path",
@@ -192,11 +199,11 @@ func TestNode_FieldPath(t *testing.T) {
Name: "age", Name: "age",
Parent: &JSONAccessNode{ Parent: &JSONAccessNode{
Name: "user", Name: "user",
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
}, },
// FieldPath() always wraps the field name in backticks // FieldPath() always wraps the field name in backticks
expected: "`" + "body_json" + ".user`.`age`", expected: "`" + bodyV2Column + ".user`.`age`",
}, },
{ {
name: "Array element field path", name: "Array element field path",
@@ -204,11 +211,11 @@ func TestNode_FieldPath(t *testing.T) {
Name: "name", Name: "name",
Parent: &JSONAccessNode{ Parent: &JSONAccessNode{
Name: "education", Name: "education",
Parent: NewRootJSONAccessNode("body_json", 32, 0), Parent: NewRootJSONAccessNode(bodyV2Column, 32, 0),
}, },
}, },
// FieldPath() always wraps the field name in backticks // FieldPath() always wraps the field name in backticks
expected: "`" + "body_json" + ".education`.`name`", expected: "`" + bodyV2Column + ".education`.`name`",
}, },
} }
@@ -236,36 +243,36 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
{ {
name: "Simple path not promoted", name: "Simple path not promoted",
key: makeKey("user.name", String, false), key: makeKey("user.name", String, false),
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: user.name - name: user.name
column: body_json column: %s
availableTypes: availableTypes:
- String - String
maxDynamicTypes: 16 maxDynamicTypes: 16
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
{ {
name: "Simple path promoted", name: "Simple path promoted",
key: makeKey("user.name", String, true), key: makeKey("user.name", String, true),
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: user.name - name: user.name
column: body_json column: %s
availableTypes: availableTypes:
- String - String
maxDynamicTypes: 16 maxDynamicTypes: 16
isTerminal: true isTerminal: true
elemType: String elemType: String
- name: user.name - name: user.name
column: body_json_promoted column: %s
availableTypes: availableTypes:
- String - String
maxDynamicTypes: 16 maxDynamicTypes: 16
maxDynamicPaths: 256 maxDynamicPaths: 256
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column, bodyPromotedColumn),
}, },
{ {
name: "Empty path returns error", name: "Empty path returns error",
@@ -278,8 +285,8 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := tt.key.SetJSONAccessPlan(JSONColumnMetadata{ err := tt.key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: "body_json", BaseColumn: bodyV2Column,
PromotedColumn: "body_json_promoted", PromotedColumn: bodyPromotedColumn,
}, types) }, types)
if tt.expectErr { if tt.expectErr {
require.Error(t, err) require.Error(t, err)
@@ -304,9 +311,9 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
{ {
name: "Single array level - JSON branch only", name: "Single array level - JSON branch only",
path: "education[].name", path: "education[].name",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -318,14 +325,14 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
maxDynamicTypes: 8 maxDynamicTypes: 8
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
{ {
name: "Single array level - both JSON and Dynamic branches", name: "Single array level - both JSON and Dynamic branches",
path: "education[].awards[].type", path: "education[].awards[].type",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -352,14 +359,14 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
maxDynamicPaths: 256 maxDynamicPaths: 256
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
{ {
name: "Deeply nested array path", name: "Deeply nested array path",
path: "interests[].entities[].reviews[].entries[].metadata[].positions[].name", path: "interests[].entities[].reviews[].entries[].metadata[].positions[].name",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: interests - name: interests
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -399,14 +406,14 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
- String - String
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
{ {
name: "ArrayAnyIndex replacement [*] to []", name: "ArrayAnyIndex replacement [*] to []",
path: "education[*].name", path: "education[*].name",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -418,7 +425,7 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
maxDynamicTypes: 8 maxDynamicTypes: 8
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
} }
@@ -426,8 +433,8 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
key := makeKey(tt.path, String, false) key := makeKey(tt.path, String, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{ err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: "body_json", BaseColumn: bodyV2Column,
PromotedColumn: "body_json_promoted", PromotedColumn: bodyPromotedColumn,
}, types) }, types)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, key.JSONPlan) require.NotNil(t, key.JSONPlan)
@@ -445,15 +452,15 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
t.Run("Non-promoted plan", func(t *testing.T) { t.Run("Non-promoted plan", func(t *testing.T) {
key := makeKey(path, String, false) key := makeKey(path, String, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{ err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: "body_json", BaseColumn: bodyV2Column,
PromotedColumn: "body_json_promoted", PromotedColumn: bodyPromotedColumn,
}, types) }, types)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, key.JSONPlan, 1) require.Len(t, key.JSONPlan, 1)
expectedYAML := ` expectedYAML := fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -480,7 +487,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
maxDynamicPaths: 256 maxDynamicPaths: 256
isTerminal: true isTerminal: true
elemType: String elemType: String
` `, bodyV2Column)
got := plansToYAML(t, key.JSONPlan) got := plansToYAML(t, key.JSONPlan)
require.YAMLEq(t, expectedYAML, got) require.YAMLEq(t, expectedYAML, got)
}) })
@@ -488,15 +495,15 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
t.Run("Promoted plan", func(t *testing.T) { t.Run("Promoted plan", func(t *testing.T) {
key := makeKey(path, String, true) key := makeKey(path, String, true)
err := key.SetJSONAccessPlan(JSONColumnMetadata{ err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: "body_json", BaseColumn: bodyV2Column,
PromotedColumn: "body_json_promoted", PromotedColumn: bodyPromotedColumn,
}, types) }, types)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, key.JSONPlan, 2) require.Len(t, key.JSONPlan, 2)
expectedYAML := ` expectedYAML := fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -524,7 +531,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
isTerminal: true isTerminal: true
elemType: String elemType: String
- name: education - name: education
column: body_json_promoted column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -554,7 +561,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
maxDynamicPaths: 256 maxDynamicPaths: 256
isTerminal: true isTerminal: true
elemType: String elemType: String
` `, bodyV2Column, bodyPromotedColumn)
got := plansToYAML(t, key.JSONPlan) got := plansToYAML(t, key.JSONPlan)
require.YAMLEq(t, expectedYAML, got) require.YAMLEq(t, expectedYAML, got)
}) })
@@ -575,11 +582,11 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
expectErr: true, expectErr: true,
}, },
{ {
name: "Very deep nesting - validates progression doesn't go negative", name: "Very deep nesting - validates progression doesn't go negative",
path: "interests[].entities[].reviews[].entries[].metadata[].positions[].name", path: "interests[].entities[].reviews[].entries[].metadata[].positions[].name",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: interests - name: interests
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -619,14 +626,14 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
- String - String
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
{ {
name: "Path with mixed scalar and array types", name: "Path with mixed scalar and array types",
path: "education[].type", path: "education[].type",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -639,20 +646,20 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
maxDynamicTypes: 8 maxDynamicTypes: 8
isTerminal: true isTerminal: true
elemType: String elemType: String
`, `, bodyV2Column),
}, },
{ {
name: "Exists with only array types available", name: "Exists with only array types available",
path: "education", path: "education",
expectedYAML: ` expectedYAML: fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
isTerminal: true isTerminal: true
elemType: Array(JSON) elemType: Array(JSON)
`, `, bodyV2Column),
}, },
} }
@@ -668,8 +675,8 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
} }
key := makeKey(tt.path, keyType, false) key := makeKey(tt.path, keyType, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{ err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: "body_json", BaseColumn: bodyV2Column,
PromotedColumn: "body_json_promoted", PromotedColumn: bodyPromotedColumn,
}, types) }, types)
if tt.expectErr { if tt.expectErr {
require.Error(t, err) require.Error(t, err)
@@ -687,15 +694,15 @@ func TestPlanJSON_TreeStructure(t *testing.T) {
path := "education[].awards[].participated[].team[].branch" path := "education[].awards[].participated[].team[].branch"
key := makeKey(path, String, false) key := makeKey(path, String, false)
err := key.SetJSONAccessPlan(JSONColumnMetadata{ err := key.SetJSONAccessPlan(JSONColumnMetadata{
BaseColumn: "body_json", BaseColumn: bodyV2Column,
PromotedColumn: "body_json_promoted", PromotedColumn: bodyPromotedColumn,
}, types) }, types)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, key.JSONPlan, 1) require.Len(t, key.JSONPlan, 1)
expectedYAML := ` expectedYAML := fmt.Sprintf(`
- name: education - name: education
column: body_json column: %s
availableTypes: availableTypes:
- Array(JSON) - Array(JSON)
maxDynamicTypes: 16 maxDynamicTypes: 16
@@ -780,7 +787,7 @@ func TestPlanJSON_TreeStructure(t *testing.T) {
maxDynamicPaths: 64 maxDynamicPaths: 64
isTerminal: true isTerminal: true
elemType: String elemType: String
` `, bodyV2Column)
got := plansToYAML(t, key.JSONPlan) got := plansToYAML(t, key.JSONPlan)
require.YAMLEq(t, expectedYAML, got) require.YAMLEq(t, expectedYAML, got)