mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-17 10:22:11 +00:00
Compare commits
87 Commits
feat/user-
...
keep-messa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcce8b4008 | ||
|
|
c2c2678125 | ||
|
|
73dc095b79 | ||
|
|
b2adbb510b | ||
|
|
4f7ec4c057 | ||
|
|
9c5a5488e2 | ||
|
|
09b6382820 | ||
|
|
9689b847f0 | ||
|
|
15e5938e95 | ||
|
|
c5ef455283 | ||
|
|
9ee23eae06 | ||
|
|
2316b5be83 | ||
|
|
0f36479787 | ||
|
|
95a9a24875 | ||
|
|
937ebc1582 | ||
|
|
dcc8173c79 | ||
|
|
4b4ef5ce58 | ||
|
|
7d1e39037c | ||
|
|
54f104db5f | ||
|
|
5b8d5fbfd3 | ||
|
|
0271be11e6 | ||
|
|
92d220c4d9 | ||
|
|
0ed8169bad | ||
|
|
ed553fb02e | ||
|
|
47daba3c17 | ||
|
|
2b3310809a | ||
|
|
542a648cc3 | ||
|
|
ab0852bbfb | ||
|
|
4c7aba680e | ||
|
|
23c247a1ba | ||
|
|
4777b13ddf | ||
|
|
2d3060bac4 | ||
|
|
9101d51920 | ||
|
|
82b82b0208 | ||
|
|
51bd760d9a | ||
|
|
2a492cc783 | ||
|
|
24afdad36c | ||
|
|
5d20019207 | ||
|
|
1963d5811d | ||
|
|
15cfccad74 | ||
|
|
a0399560e3 | ||
|
|
265e337d5c | ||
|
|
bb8c874755 | ||
|
|
13cbe03d64 | ||
|
|
93621c29b7 | ||
|
|
2c691b5a75 | ||
|
|
cd7e1bb114 | ||
|
|
a1d2ec8b8a | ||
|
|
8bbafb52d5 | ||
|
|
075cfab463 | ||
|
|
86bccaac0c | ||
|
|
de1aac63c0 | ||
|
|
14fe8745b5 | ||
|
|
4013c7ee03 | ||
|
|
0d34360e0b | ||
|
|
d204c89dec | ||
|
|
8dd33c1ab7 | ||
|
|
8e5c3d5ae1 | ||
|
|
d45bb52f33 | ||
|
|
e71818292d | ||
|
|
37557f7f24 | ||
|
|
27ff102660 | ||
|
|
cb2aa4cffd | ||
|
|
58d1d84ec7 | ||
|
|
d8e116a7bc | ||
|
|
6a48bdc37e | ||
|
|
ffb62432f8 | ||
|
|
57c51f070c | ||
|
|
36becfc7a2 | ||
|
|
8e71de09f3 | ||
|
|
56de92de73 | ||
|
|
62b10f8e77 | ||
|
|
20b53d7856 | ||
|
|
8f2c506304 | ||
|
|
7b5b9027dd | ||
|
|
b77f97fcb7 | ||
|
|
62942a4162 | ||
|
|
349bbbbf1d | ||
|
|
1966a7a5f6 | ||
|
|
a4eed9ff13 | ||
|
|
24d1ee33b5 | ||
|
|
3402203021 | ||
|
|
e8e4897cc8 | ||
|
|
96fb88aaee | ||
|
|
5a00e6c2cd | ||
|
|
e2500cff7d | ||
|
|
4864c3bc37 |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -127,12 +127,15 @@
|
||||
/frontend/src/pages/DashboardsListPage/ @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
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Public Dashboard Page
|
||||
|
||||
|
||||
59
.github/workflows/mergequeueci.yaml
vendored
Normal file
59
.github/workflows/mergequeueci.yaml
vendored
Normal 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."
|
||||
@@ -1768,19 +1768,19 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
last_used:
|
||||
lastObservedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
service_account_id:
|
||||
serviceAccountId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
@@ -1788,9 +1788,9 @@ components:
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expires_at
|
||||
- last_used
|
||||
- service_account_id
|
||||
- expiresAt
|
||||
- lastObservedAt
|
||||
- serviceAccountId
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKey:
|
||||
properties:
|
||||
@@ -1804,14 +1804,14 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
@@ -1833,13 +1833,16 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
deletedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgID:
|
||||
orgId:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
@@ -1856,18 +1859,19 @@ components:
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgID
|
||||
- orgId
|
||||
- deletedAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
|
||||
@@ -2,39 +2,45 @@ module base
|
||||
|
||||
type organisation
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
|
||||
type user
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type serviceaccount
|
||||
relations
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type anonymous
|
||||
|
||||
type role
|
||||
relations
|
||||
define assignee: [user, anonymous]
|
||||
define assignee: [user, serviceaccount, anonymous]
|
||||
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresources
|
||||
relations
|
||||
define create: [user, role#assignee]
|
||||
define list: [user, role#assignee]
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresource
|
||||
relations
|
||||
define read: [user, anonymous, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, anonymous, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
define block: [user, role#assignee]
|
||||
define block: [user, serviceaccount, role#assignee]
|
||||
|
||||
|
||||
type telemetryresource
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
2021
frontend/public/Images/allInOneLightMode.svg
Normal file
2021
frontend/public/Images/allInOneLightMode.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 214 KiB |
@@ -128,6 +128,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
isAdmin &&
|
||||
(path === ROUTES.SETTINGS ||
|
||||
path === ROUTES.ORG_SETTINGS ||
|
||||
path === ROUTES.MEMBERS_SETTINGS ||
|
||||
path === ROUTES.BILLING ||
|
||||
path === ROUTES.MY_SETTINGS);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
@@ -321,6 +320,19 @@ function App(): JSX.Element {
|
||||
// Session Replay
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
const sessionReplayUrl = posthog.get_session_replay_url?.({
|
||||
withTimestamp: true,
|
||||
});
|
||||
if (sessionReplayUrl) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.contexts = {
|
||||
...event.contexts,
|
||||
posthog: { session_replay_url: sessionReplayUrl },
|
||||
};
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
setIsSentryInitialized(true);
|
||||
@@ -371,28 +383,26 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -2100,7 +2100,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2113,7 +2113,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
last_used: Date;
|
||||
lastObservedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2121,7 +2121,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
service_account_id: string;
|
||||
serviceAccountId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2145,7 +2145,7 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2173,6 +2173,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
deletedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2188,7 +2193,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgID: string;
|
||||
orgId: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2209,7 +2214,7 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -297,7 +297,11 @@ function CustomTimePicker({
|
||||
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
|
||||
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
@@ -548,3 +549,49 @@ export const DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY: Record<
|
||||
[DataTypes.ArrayBool]: 'boolAttributeValues',
|
||||
[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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,14 +30,15 @@ export default function CustomDomainEditModal({
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const initialSubdomain = customDomainSubdomain ?? '';
|
||||
const [value, setValue] = useState(initialSubdomain);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
setValue(initialSubdomain);
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
}, [isOpen, initialSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
@@ -58,6 +59,11 @@ export default function CustomDomainEditModal({
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (value === initialSubdomain) {
|
||||
setValidationError('Input is unchanged');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
@@ -84,7 +90,7 @@ export default function CustomDomainEditModal({
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
const statusIcon = ((): JSX.Element | null => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
|
||||
@@ -95,7 +101,9 @@ export default function CustomDomainEditModal({
|
||||
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CircleCheck size={16} color={Color.BG_FOREST_500} />;
|
||||
return value && value.length >= 3 ? (
|
||||
<CircleCheck size={16} color={Color.BG_FOREST_500} />
|
||||
) : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -189,7 +197,7 @@ export default function CustomDomainEditModal({
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || value === initialSubdomain}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row.workspace-name-hidden {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -117,32 +121,6 @@
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
|
||||
@@ -69,8 +69,9 @@ function DomainUpdateToast({
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { org } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
@@ -175,7 +176,8 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const planName = activeLicense?.plan?.name;
|
||||
const workspaceName =
|
||||
org?.[0]?.displayName || customDomainSubdomain || activeHost?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
@@ -191,106 +193,98 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
{!!workspaceName && (
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
<span className="custom-domain-card-org-name">{workspaceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
<div
|
||||
className={`custom-domain-card-meta-row ${
|
||||
!workspaceName ? 'workspace-name-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
|
||||
@@ -239,4 +239,87 @@ describe('CustomDomainSettings', () => {
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My Org Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('custom-host')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to activeHost.name when neither org name nor custom domain exists', async () => {
|
||||
const onlyDefaultHostResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: mockHostsResponse.data.hosts
|
||||
? [mockHostsResponse.data.hosts[0]]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(onlyDefaultHostResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
// 'accepted-starfish' is the default host's name
|
||||
expect(await screen.findByText('accepted-starfish')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the card name row if workspaceName is totally falsy', async () => {
|
||||
const emptyHostsResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(emptyHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { container } = render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
await screen.findByRole('button', { name: /edit workspace link/i });
|
||||
|
||||
expect(
|
||||
container.querySelector('.custom-domain-card-name-row'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -34,11 +34,6 @@ const mockSafeNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -69,7 +64,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -110,7 +105,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -149,7 +144,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -187,9 +182,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
|
||||
const mockContextValue: IDashboardContext = {
|
||||
isDashboardSliderOpen: false,
|
||||
isDashboardLocked: false,
|
||||
handleToggleDashboardSlider: jest.fn(),
|
||||
handleDashboardLockToggle: jest.fn(),
|
||||
dashboardResponse: {} as IDashboardContext['dashboardResponse'],
|
||||
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
|
||||
@@ -199,8 +192,6 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
setLayouts: jest.fn(),
|
||||
setSelectedDashboard: jest.fn(),
|
||||
updatedTimeRef: { current: null },
|
||||
toScrollWidgetId: '',
|
||||
setToScrollWidgetId: jest.fn(),
|
||||
updateLocalStorageDashboardVariables: jest.fn(),
|
||||
dashboardQueryRangeCalled: false,
|
||||
setDashboardQueryRangeCalled: jest.fn(),
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { sortLayout } from 'providers/Dashboard/util';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
@@ -48,10 +49,10 @@ import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardGraphSlider from '../ComponentsSlider';
|
||||
import DashboardSettings from '../DashboardSettings';
|
||||
import { Base64Icons } from '../DashboardSettings/General/utils';
|
||||
import DashboardVariableSelection from '../DashboardVariablesSelection';
|
||||
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
import { VariablesSettingsTab } from './types';
|
||||
import {
|
||||
@@ -69,6 +70,9 @@ interface DashboardDescriptionProps {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const {
|
||||
selectedDashboard,
|
||||
panelMap,
|
||||
@@ -77,7 +81,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
setLayouts,
|
||||
isDashboardLocked,
|
||||
setSelectedDashboard,
|
||||
handleToggleDashboardSlider,
|
||||
handleDashboardLockToggle,
|
||||
} = useDashboard();
|
||||
|
||||
@@ -145,14 +148,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const [addPanelPermission] = useComponentPermission(permissions, userRole);
|
||||
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
handleToggleDashboardSlider(true);
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
logEvent('Dashboard Detail: Add new panel clicked', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
numberOfPanels: selectedDashboard?.data.widgets?.length,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [handleToggleDashboardSlider]);
|
||||
}, [setIsPanelTypeSelectionModalOpen]);
|
||||
|
||||
const handleLockDashboardToggle = (): void => {
|
||||
setIsDashbordSettingsOpen(false);
|
||||
@@ -521,7 +524,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
<DashboardVariableSelection />
|
||||
</section>
|
||||
)}
|
||||
<DashboardGraphSlider />
|
||||
<PanelTypeSelectionModal />
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'hooks/dashboard/useDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
@@ -30,7 +29,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
const { updateUrlVariable } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
@@ -50,15 +49,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
(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
|
||||
const variableOrderKey = useMemo(() => {
|
||||
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.graph-selection {
|
||||
.panel-type-selection-modal {
|
||||
.ant-modal-content {
|
||||
width: 515px;
|
||||
max-height: 646px;
|
||||
@@ -76,6 +76,11 @@
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-type-text {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +119,7 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.graph-selection {
|
||||
.panel-type-selection-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
@@ -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);
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Table,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Items: ItemsProps[] = [
|
||||
export const PanelTypesWithData: ItemsProps[] = [
|
||||
{
|
||||
name: PANEL_TYPES.TIME_SERIES,
|
||||
icon: <LineChart size={16} color={Color.BG_ROBIN_400} />,
|
||||
@@ -52,5 +52,3 @@ export interface ItemsProps {
|
||||
icon: JSX.Element;
|
||||
display: string;
|
||||
}
|
||||
|
||||
export default Items;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
jest.mock('providers/Dashboard/helpers/scrollToWidgetIdHelper');
|
||||
|
||||
type MockHTMLElement = {
|
||||
scrollIntoView: jest.Mock;
|
||||
@@ -18,25 +18,35 @@ function createMockElement(): MockHTMLElement {
|
||||
}
|
||||
|
||||
describe('useScrollWidgetIntoView', () => {
|
||||
const mockedUseDashboard = useDashboard as jest.MockedFunction<
|
||||
typeof useDashboard
|
||||
const mockedUseScrollToWidgetIdStore = useScrollToWidgetIdStore as jest.MockedFunction<
|
||||
typeof useScrollToWidgetIdStore
|
||||
>;
|
||||
|
||||
let mockElement: MockHTMLElement;
|
||||
let ref: React.RefObject<HTMLDivElement>;
|
||||
let setToScrollWidgetId: jest.Mock;
|
||||
|
||||
function mockStore(toScrollWidgetId: string): void {
|
||||
const storeState = { toScrollWidgetId, setToScrollWidgetId };
|
||||
mockedUseScrollToWidgetIdStore.mockImplementation(
|
||||
(selector) =>
|
||||
selector(
|
||||
(storeState as unknown) as Parameters<typeof selector>[0],
|
||||
) as ReturnType<typeof useScrollToWidgetIdStore>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockElement = createMockElement();
|
||||
ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
setToScrollWidgetId = jest.fn();
|
||||
});
|
||||
|
||||
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'widget-id',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('widget-id');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
@@ -49,16 +59,7 @@ describe('useScrollWidgetIntoView', () => {
|
||||
});
|
||||
|
||||
it('does nothing when toScrollWidgetId does not match widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'other-widget',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('other-widget');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
/**
|
||||
* Scrolls the given widget container into view when the dashboard
|
||||
@@ -11,7 +11,10 @@ export function useScrollWidgetIntoView<T extends HTMLElement>(
|
||||
widgetId: string,
|
||||
widgetContainerRef: RefObject<T>,
|
||||
): void {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const toScrollWidgetId = useScrollToWidgetIdStore((s) => s.toScrollWidgetId);
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widgetId) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -34,8 +33,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -32,8 +31,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -33,8 +32,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import setRetentionApi from 'api/settings/setRetention';
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import LicenseKeyRow from 'container/GeneralSettings/LicenseKeyRow/LicenseKeyRow';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
@@ -81,7 +82,7 @@ function GeneralSettings({
|
||||
logsTtlValuesPayload,
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { user, activeLicense } = useAppContext();
|
||||
|
||||
const [setRetentionPermission] = useComponentPermission(
|
||||
['set_retention_period'],
|
||||
@@ -680,7 +681,15 @@ function GeneralSettings({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{(showCustomDomainSettings || activeLicense?.key) && (
|
||||
<div className="custom-domain-card">
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{showCustomDomainSettings && activeLicense?.key && (
|
||||
<div className="custom-domain-card-divider" />
|
||||
)}
|
||||
{activeLicense?.key && <LicenseKeyRow />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="retention-controls-container">
|
||||
<div className="retention-controls-header">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
.license-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
gap: var(--spacing-5);
|
||||
|
||||
&__left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
padding: 1px 2px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Copy, KeyRound } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseKeyRow.styles.scss';
|
||||
|
||||
function LicenseKeyRow(): JSX.Element | null {
|
||||
const { activeLicense } = useAppContext();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (!activeLicense?.key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopyLicenseKey = (text: string): void => {
|
||||
copyToClipboard(text);
|
||||
toast.success('License key copied to clipboard.', { richColors: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="license-key-row">
|
||||
<span className="license-key-row__left">
|
||||
<KeyRound size={14} />
|
||||
<span className="license-key-row__label">SigNoz License Key</span>
|
||||
</span>
|
||||
<span className="license-key-row__value">
|
||||
<code className="license-key-row__code">
|
||||
{getMaskedKey(activeLicense.key)}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-label="Copy license key"
|
||||
data-testid="license-key-row-copy-btn"
|
||||
className="license-key-row__copy-btn"
|
||||
onClick={(): void => handleCopyLicenseKey(activeLicense.key)}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseKeyRow;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LicenseKeyRow from '../LicenseKeyRow';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LicenseKeyRow', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when activeLicense key is absent', () => {
|
||||
const { container } = render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: { activeLicense: null },
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders label and masked key when activeLicense key exists', () => {
|
||||
render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: { key: 'abcdefghij' } as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('SigNoz License Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('ab·······ij')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls copyToClipboard and shows success toast when clipboard is available', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<LicenseKeyRow />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /copy license key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('test-key');
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'License key copied to clipboard.',
|
||||
{
|
||||
richColors: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,17 +9,18 @@ import DashboardSettings from 'container/DashboardContainer/DashboardSettings';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
|
||||
import './DashboardEmptyState.styles.scss';
|
||||
|
||||
export default function DashboardEmptyState(): JSX.Element {
|
||||
const {
|
||||
selectedDashboard,
|
||||
isDashboardLocked,
|
||||
handleToggleDashboardSlider,
|
||||
} = useDashboard();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||
|
||||
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState<boolean>(
|
||||
@@ -41,14 +42,14 @@ export default function DashboardEmptyState(): JSX.Element {
|
||||
const [addPanelPermission] = useComponentPermission(permissions, userRole);
|
||||
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
handleToggleDashboardSlider(true);
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
logEvent('Dashboard Detail: Add new panel clicked', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
numberOfPanels: selectedDashboard?.data.widgets?.length,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [handleToggleDashboardSlider]);
|
||||
}, [setIsPanelTypeSelectionModalOpen]);
|
||||
|
||||
const onConfigureClick = useCallback((): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { Select, Typography } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
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 { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -59,7 +59,7 @@ function PanelTypeSelector({
|
||||
data-testid="panel-change-select"
|
||||
disabled={disabled}
|
||||
>
|
||||
{GraphTypes.map((item) => (
|
||||
{PanelTypesWithData.map((item) => (
|
||||
<Option key={item.name} value={item.name}>
|
||||
<div className="view-panel-select-option">
|
||||
<div className="icon">{item.icon}</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
|
||||
@@ -67,11 +68,7 @@ function GridCardGraph({
|
||||
const [isInternalServerError, setIsInternalServerError] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
setDashboardQueryRangeCalled,
|
||||
} = useDashboard();
|
||||
const { setDashboardQueryRangeCalled } = useDashboard();
|
||||
|
||||
const {
|
||||
minTime,
|
||||
@@ -109,20 +106,11 @@ function GridCardGraph({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const widgetContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isVisible = useIntersectionObserver(graphRef, undefined, true);
|
||||
const isVisible = useIntersectionObserver(widgetContainerRef, undefined, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget?.id || '', widgetContainerRef);
|
||||
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
@@ -306,7 +294,7 @@ function GridCardGraph({
|
||||
: headerMenuList;
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={widgetContainerRef}>
|
||||
{isEmptyLayout ? (
|
||||
<EmptyWidget />
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { setSelectedRowWidgetId } from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
@@ -34,11 +35,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
|
||||
} = props;
|
||||
const [isRowSettingsOpen, setIsRowSettingsOpen] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
handleToggleDashboardSlider,
|
||||
selectedDashboard,
|
||||
isDashboardLocked,
|
||||
} = useDashboard();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||
|
||||
const permissions: ComponentTypes[] = ['add_panel'];
|
||||
const { user } = useAppContext();
|
||||
@@ -87,7 +88,7 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
|
||||
}
|
||||
|
||||
setSelectedRowWidgetId(selectedDashboard.id, id);
|
||||
handleToggleDashboardSlider(true);
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
New Panel
|
||||
|
||||
@@ -15,6 +15,7 @@ import ROUTES from 'constants/routes';
|
||||
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
@@ -43,6 +44,7 @@ const homeInterval = 30 * 60 * 1000;
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function Home(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [startTime, setStartTime] = 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">
|
||||
<img
|
||||
src="/Images/allInOne.svg"
|
||||
src={
|
||||
isDarkMode
|
||||
? '/Images/allInOne.svg'
|
||||
: '/Images/allInOneLightMode.svg'
|
||||
}
|
||||
alt="checklist-img"
|
||||
className="checklist-img"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ import NewWidget from 'container/NewWidget';
|
||||
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import i18n from 'ReactI18';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -104,15 +103,13 @@ describe('LogsPanelComponent', () => {
|
||||
const renderComponent = async (): Promise<void> => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Typography } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseSection.styles.scss';
|
||||
|
||||
@@ -12,15 +13,6 @@ function LicenseSection(): JSX.Element | null {
|
||||
const { notifications } = useNotifications();
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const getMaskedKey = (key: string): string => {
|
||||
if (!key || key.length < 4) {
|
||||
return key || 'N/A';
|
||||
}
|
||||
return `${key.substring(0, 2)}********${key
|
||||
.substring(key.length - 2)
|
||||
.trim()}`;
|
||||
};
|
||||
|
||||
const handleCopyKey = (text: string): void => {
|
||||
handleCopyToClipboard(text);
|
||||
notifications.success({
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('MySettings Flows', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
|
||||
expect(within(container).getByText('ab·······cd')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not mask license key if it is too short', () => {
|
||||
|
||||
@@ -8,28 +8,15 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
import {
|
||||
getDefaultWidgetData,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
// import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { defaultTo, isUndefined } from 'lodash-es';
|
||||
import { Atom, Terminal } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import {
|
||||
getNextWidgets,
|
||||
getPreviousWidgets,
|
||||
getSelectedWidgetIndex,
|
||||
} from 'providers/Dashboard/util';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ClickHouseQueryContainer from './QueryBuilder/clickHouse';
|
||||
@@ -40,77 +27,25 @@ function QuerySection({
|
||||
selectedGraph,
|
||||
queryRangeKey,
|
||||
isLoadingQueries,
|
||||
selectedWidget,
|
||||
dashboardVersion,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
isNewPanel,
|
||||
}: QueryProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
handleRunQuery: handleRunQueryFromQueryBuilder,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { selectedDashboard, setSelectedDashboard } = useDashboard();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { widgets } = selectedDashboard?.data || {};
|
||||
|
||||
const getWidget = useCallback(() => {
|
||||
const widgetId = urlQuery.get('widgetId');
|
||||
return defaultTo(
|
||||
widgets?.find((e) => e.id === widgetId),
|
||||
getDefaultWidgetData(widgetId || '', selectedGraph),
|
||||
);
|
||||
}, [urlQuery, widgets, selectedGraph]);
|
||||
|
||||
const selectedWidget = getWidget() as Widgets;
|
||||
|
||||
const { query } = selectedWidget;
|
||||
|
||||
useShareBuilderUrl({ defaultValue: query });
|
||||
|
||||
const handleStageQuery = useCallback(
|
||||
(query: Query): void => {
|
||||
if (selectedDashboard === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedWidgetIndex = getSelectedWidgetIndex(
|
||||
selectedDashboard,
|
||||
selectedWidget.id,
|
||||
);
|
||||
|
||||
const previousWidgets = getPreviousWidgets(
|
||||
selectedDashboard,
|
||||
selectedWidgetIndex,
|
||||
);
|
||||
|
||||
const nextWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
|
||||
|
||||
setSelectedDashboard({
|
||||
...selectedDashboard,
|
||||
data: {
|
||||
...selectedDashboard?.data,
|
||||
widgets: [
|
||||
...previousWidgets,
|
||||
{
|
||||
...selectedWidget,
|
||||
query,
|
||||
},
|
||||
...nextWidgets,
|
||||
],
|
||||
},
|
||||
});
|
||||
handleRunQueryFromQueryBuilder();
|
||||
},
|
||||
[
|
||||
selectedDashboard,
|
||||
selectedWidget,
|
||||
setSelectedDashboard,
|
||||
handleRunQueryFromQueryBuilder,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(qCategory: string): void => {
|
||||
const currentQueryType = qCategory as EQueryType;
|
||||
@@ -123,19 +58,16 @@ function QuerySection({
|
||||
);
|
||||
|
||||
const handleRunQuery = (): void => {
|
||||
const widgetId = urlQuery.get('widgetId');
|
||||
const isNewPanel = isUndefined(widgets?.find((e) => e.id === widgetId));
|
||||
|
||||
logEvent('Panel Edit: Stage and run query', {
|
||||
dataSource: currentQuery.builder?.queryData?.[0]?.dataSource,
|
||||
panelType: selectedWidget.panelTypes,
|
||||
queryType: currentQuery.queryType,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
isNewPanel,
|
||||
});
|
||||
handleStageQuery(currentQuery);
|
||||
handleRunQueryFromQueryBuilder();
|
||||
};
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
@@ -164,7 +96,7 @@ function QuerySection({
|
||||
panelType={selectedGraph}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={selectedGraph !== PANEL_TYPES.LIST}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
version={dashboardVersion || 'v3'}
|
||||
isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
|
||||
queryComponents={queryComponents}
|
||||
signalSourceChangeEnabled
|
||||
@@ -204,7 +136,7 @@ function QuerySection({
|
||||
queryComponents,
|
||||
selectedGraph,
|
||||
filterConfigs,
|
||||
selectedDashboard?.data?.version,
|
||||
dashboardVersion,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
@@ -261,6 +193,11 @@ interface QueryProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
queryRangeKey?: QueryKey;
|
||||
isLoadingQueries?: boolean;
|
||||
selectedWidget: Widgets;
|
||||
dashboardVersion?: string;
|
||||
dashboardId?: string;
|
||||
dashboardName?: string;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
|
||||
@@ -30,6 +30,8 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
selectedDashboard,
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
@@ -75,6 +77,11 @@ function LeftContainer({
|
||||
selectedGraph={selectedGraph}
|
||||
queryRangeKey={queryRangeKey}
|
||||
isLoadingQueries={queryResponse.isFetching}
|
||||
selectedWidget={selectedWidget}
|
||||
dashboardVersion={ENTITY_VERSION_V5}
|
||||
dashboardId={selectedDashboard?.id}
|
||||
dashboardName={selectedDashboard?.data.title}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
{selectedGraph === PANEL_TYPES.LIST && (
|
||||
<ExplorerColumnsRenderer
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.column-unit-selector {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.heading {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -30,6 +32,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -72,22 +72,24 @@ export function ColumnUnitSelector(
|
||||
return (
|
||||
<section className="column-unit-selector">
|
||||
<Typography.Text className="heading">Column Units</Typography.Text>
|
||||
{aggregationQueries.map(({ value, label }) => {
|
||||
const baseQueryName = value.split('.')[0];
|
||||
return (
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
key={value}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="column-unit-selector-content">
|
||||
{aggregationQueries.map(({ value, label }) => {
|
||||
const baseQueryName = value.split('.')[0];
|
||||
return (
|
||||
<YAxisUnitSelectorV2
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
}
|
||||
fieldLabel={label}
|
||||
key={value}
|
||||
selectedQueryName={baseQueryName}
|
||||
// Update the column unit value automatically only in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
expect(
|
||||
screen.getByRole('button', { name: /context link/i }),
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import { Button, Modal } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
@@ -134,11 +134,16 @@ function ContextLinks({
|
||||
|
||||
return (
|
||||
<div className="context-links-container">
|
||||
<Typography.Text className="context-links-text">
|
||||
Context Links
|
||||
</Typography.Text>
|
||||
|
||||
<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>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -160,16 +165,6 @@ function ContextLinks({
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</OverlayScrollbar>
|
||||
|
||||
{/* button to add context link */}
|
||||
<Button
|
||||
type="primary"
|
||||
className="add-context-link-button"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={handleAddContextLink}
|
||||
>
|
||||
Context Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.context-links-text {
|
||||
@@ -110,10 +109,7 @@
|
||||
}
|
||||
|
||||
.add-context-link-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.right-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 48px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -24,14 +25,14 @@
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.name-description {
|
||||
.control-container {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.name-description {
|
||||
padding: 0 0 4px 0;
|
||||
|
||||
.typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -88,9 +89,6 @@
|
||||
.panel-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 12px 16px 12px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -104,6 +102,7 @@
|
||||
}
|
||||
|
||||
.panel-type-select {
|
||||
width: 100%;
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
@@ -137,7 +136,6 @@
|
||||
}
|
||||
|
||||
.fill-gaps {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
justify-content: space-between;
|
||||
@@ -156,31 +154,24 @@
|
||||
letter-spacing: 0.52px;
|
||||
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,
|
||||
.decimal-precision-selector {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.decimal-precision-selector,
|
||||
.legend-position {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-colors {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel-time-text {
|
||||
margin-top: 16px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
@@ -193,7 +184,6 @@
|
||||
|
||||
.y-axis-unit-selector,
|
||||
.y-axis-unit-selector-v2 {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -278,11 +268,8 @@
|
||||
}
|
||||
|
||||
.stack-chart {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
@@ -296,11 +283,6 @@
|
||||
}
|
||||
|
||||
.bucket-config {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
@@ -352,16 +334,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.alerts {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
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;
|
||||
|
||||
.left-section {
|
||||
@@ -387,6 +366,16 @@
|
||||
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 {
|
||||
@@ -418,9 +407,6 @@
|
||||
}
|
||||
|
||||
.name-description {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
@@ -441,8 +427,6 @@
|
||||
}
|
||||
|
||||
.panel-config {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
@@ -478,6 +462,9 @@
|
||||
.fill-gaps-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.fill-gaps-text-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-config {
|
||||
@@ -530,7 +517,7 @@
|
||||
}
|
||||
|
||||
.alerts {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.left-section {
|
||||
.bell-icon {
|
||||
@@ -549,6 +536,10 @@
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.thresholds-section {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.threshold-selector-container {
|
||||
padding: 12px;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.threshold-select {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { Typography } from 'antd';
|
||||
import { Button } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
|
||||
import { Antenna, Plus } from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import Threshold from './Threshold';
|
||||
@@ -68,11 +68,14 @@ function ThresholdSelector({
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="threshold-selector-container">
|
||||
<div className="threshold-select" onClick={addThresholdHandler}>
|
||||
<div className="left-section">
|
||||
<Antenna size={14} className="icon" />
|
||||
<Typography.Text className="text">Thresholds</Typography.Text>
|
||||
</div>
|
||||
<Plus size={14} onClick={addThresholdHandler} className="icon" />
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus size={14} />}
|
||||
style={{ width: '100%' }}
|
||||
onClick={addThresholdHandler}
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
</div>
|
||||
{thresholds.map((threshold, idx) => (
|
||||
<Threshold
|
||||
|
||||
@@ -8,7 +8,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -96,9 +95,7 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
|
||||
<Provider store={createMockStore()}>
|
||||
<AppContext.Provider value={createMockAppContext() as IAppContext}>
|
||||
<ErrorModalProvider>
|
||||
<DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</ErrorModalProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -14,23 +14,30 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
|
||||
import GraphTypes, {
|
||||
import {
|
||||
ItemsProps,
|
||||
} from 'container/DashboardContainer/ComponentsSlider/menuItems';
|
||||
PanelTypesWithData,
|
||||
} from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
Antenna,
|
||||
Axis3D,
|
||||
ConciergeBell,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LineChart,
|
||||
Link,
|
||||
Pencil,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
Spline,
|
||||
SquareArrowOutUpRight,
|
||||
} from 'lucide-react';
|
||||
@@ -46,6 +53,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
||||
import SettingsSection from './components/SettingsSection/SettingsSection';
|
||||
import {
|
||||
panelTypeVsBucketConfig,
|
||||
panelTypeVsColumnUnitPreferences,
|
||||
@@ -143,7 +151,7 @@ function RightContainer({
|
||||
);
|
||||
|
||||
const selectedGraphType =
|
||||
GraphTypes.find((e) => e.name === selectedGraph)?.display || '';
|
||||
PanelTypesWithData.find((e) => e.name === selectedGraph)?.display || '';
|
||||
|
||||
const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView');
|
||||
|
||||
@@ -169,7 +177,7 @@ function RightContainer({
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
|
||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(PanelTypesWithData);
|
||||
|
||||
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
|
||||
return Object.entries(dashboardVariables).map(([, value]) => ({
|
||||
@@ -178,6 +186,21 @@ function RightContainer({
|
||||
}));
|
||||
}, [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 => {
|
||||
setCursorPos(pos);
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
@@ -193,6 +216,15 @@ function RightContainer({
|
||||
}, 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 pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||
updateCursorAndDropdown(inputValue, pos);
|
||||
@@ -241,7 +273,7 @@ function RightContainer({
|
||||
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
|
||||
);
|
||||
} else {
|
||||
setGraphTypes(GraphTypes);
|
||||
setGraphTypes(PanelTypesWithData);
|
||||
}
|
||||
}, [currentQuery]);
|
||||
|
||||
@@ -263,269 +295,297 @@ function RightContainer({
|
||||
<div className="right-container">
|
||||
<section className="header">
|
||||
<div className="purple-dot" />
|
||||
<Typography.Text className="header-text">Panel details</Typography.Text>
|
||||
<Typography.Text className="header-text">Panel Settings</Typography.Text>
|
||||
</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 && (
|
||||
<Space className="fill-gaps">
|
||||
<Typography className="fill-gaps-text">Fill gaps</Typography>
|
||||
<Switch
|
||||
checked={isFillSpans}
|
||||
size="small"
|
||||
onChange={(checked): void => setIsFillSpans(checked)}
|
||||
<SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
|
||||
<section className="name-description control-container">
|
||||
<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)}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{allowPanelTimePreference && (
|
||||
<>
|
||||
<Typography.Text className="panel-time-text">
|
||||
Panel Time Preference
|
||||
</Typography.Text>
|
||||
<TimePreference
|
||||
{...{
|
||||
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'
|
||||
</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)
|
||||
}
|
||||
// Only update the y-axis unit value automatically in create mode
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
rootClassName="description-input"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
{allowDecimalPrecision && (
|
||||
<section className="decimal-precision-selector">
|
||||
<Typography.Text className="typography">
|
||||
Decimal Precision
|
||||
</Typography.Text>
|
||||
<section className="panel-config">
|
||||
<SettingsSection
|
||||
title="Visualization"
|
||||
defaultOpen
|
||||
icon={<LayoutDashboard size={14} />}
|
||||
>
|
||||
<section className="panel-type control-container">
|
||||
<Typography.Text className="typography">Panel Type</Typography.Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
||||
{ 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%' }}
|
||||
onChange={setGraphHandler}
|
||||
value={selectedGraph}
|
||||
className="panel-type-select"
|
||||
defaultValue={PrecisionOptionsEnum.TWO}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowSoftMinMax && (
|
||||
<section className="soft-min-max">
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Min</Typography.Text>
|
||||
<InputNumber
|
||||
type="number"
|
||||
value={softMin}
|
||||
onChange={softMinHandler}
|
||||
rootClassName="input"
|
||||
{allowPanelTimePreference && (
|
||||
<section className="panel-time-preference control-container">
|
||||
<Typography.Text className="panel-time-text">
|
||||
Panel Time Preference
|
||||
</Typography.Text>
|
||||
<TimePreference
|
||||
{...{
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Max</Typography.Text>
|
||||
<InputNumber
|
||||
value={softMax}
|
||||
type="number"
|
||||
rootClassName="input"
|
||||
onChange={softMaxHandler}
|
||||
)}
|
||||
|
||||
{allowStackingBarChart && (
|
||||
<section className="stack-chart control-container">
|
||||
<Typography.Text className="label">Stack series</Typography.Text>
|
||||
<Switch
|
||||
checked={stackedBarChart}
|
||||
size="small"
|
||||
onChange={(checked): void => setStackedBarChart(checked)}
|
||||
/>
|
||||
</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 && (
|
||||
<section className="stack-chart">
|
||||
<Typography.Text className="label">Stack series</Typography.Text>
|
||||
<Switch
|
||||
checked={stackedBarChart}
|
||||
size="small"
|
||||
onChange={(checked): void => setStackedBarChart(checked)}
|
||||
/>
|
||||
</section>
|
||||
{isAxisSectionVisible && (
|
||||
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
|
||||
{allowSoftMinMax && (
|
||||
<section className="soft-min-max">
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Min</Typography.Text>
|
||||
<InputNumber
|
||||
type="number"
|
||||
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 && (
|
||||
<section className="bucket-config">
|
||||
<Typography.Text className="label">Number of buckets</Typography.Text>
|
||||
<InputNumber
|
||||
value={bucketCount || null}
|
||||
type="number"
|
||||
min={0}
|
||||
rootClassName="bucket-input"
|
||||
placeholder="Default: 30"
|
||||
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)}
|
||||
<SettingsSection title="Histogram / Buckets">
|
||||
<section className="bucket-config control-container">
|
||||
<Typography.Text className="label">Number of buckets</Typography.Text>
|
||||
<InputNumber
|
||||
value={bucketCount || null}
|
||||
type="number"
|
||||
min={0}
|
||||
rootClassName="bucket-input"
|
||||
placeholder="Default: 30"
|
||||
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)}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -541,17 +601,25 @@ function RightContainer({
|
||||
)}
|
||||
|
||||
{allowContextLinks && (
|
||||
<section className="context-links">
|
||||
<SettingsSection
|
||||
title="Context Links"
|
||||
icon={<Link size={14} />}
|
||||
defaultOpen={!!contextLinks.linksData.length}
|
||||
>
|
||||
<ContextLinks
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
selectedWidget={selectedWidget}
|
||||
/>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{allowThreshold && (
|
||||
<section>
|
||||
<SettingsSection
|
||||
title="Thresholds"
|
||||
icon={<Antenna size={14} />}
|
||||
defaultOpen={!!thresholds.length}
|
||||
>
|
||||
<ThresholdSelector
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
@@ -559,7 +627,7 @@ function RightContainer({
|
||||
selectedGraph={selectedGraph}
|
||||
columnUnits={columnUnits}
|
||||
/>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ const checkStackSeriesState = (
|
||||
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
|
||||
|
||||
const stackSeriesSection = container.querySelector(
|
||||
'section > .stack-chart',
|
||||
'.stack-chart',
|
||||
) as HTMLElement;
|
||||
expect(stackSeriesSection).toBeInTheDocument();
|
||||
|
||||
@@ -310,12 +310,12 @@ describe('Stacking bar in new panel', () => {
|
||||
|
||||
const { container, getByText } = render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="">
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
|
||||
expect(getByText('Stack series')).toBeInTheDocument();
|
||||
|
||||
// Verify section exists
|
||||
const section = container.querySelector('section > .stack-chart');
|
||||
const section = container.querySelector('.stack-chart');
|
||||
expect(section).toBeInTheDocument();
|
||||
|
||||
// Verify switch is present and enabled (ant-switch-checked)
|
||||
@@ -356,11 +356,11 @@ describe('when switching to BAR panel type', () => {
|
||||
|
||||
it('should preserve saved stacking value of true', async () => {
|
||||
const { getByTestId, getByText, container } = render(
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="">
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</DashboardProvider>,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useParams } from 'react-router-dom';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -32,8 +32,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
import {
|
||||
clearSelectedRowWidgetId,
|
||||
getSelectedRowWidgetId,
|
||||
@@ -82,16 +81,15 @@ import {
|
||||
import './NewWidget.styles.scss';
|
||||
|
||||
function NewWidget({
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
@@ -136,8 +134,6 @@ function NewWidget({
|
||||
|
||||
const query = useUrlQuery();
|
||||
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
|
||||
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -283,11 +279,10 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
columnWidths: selectedWidget.columnWidths,
|
||||
contextLinks,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnUnits,
|
||||
currentQuery,
|
||||
@@ -310,8 +305,8 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
contextLinks,
|
||||
selectedWidget.columnWidths,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -444,6 +439,19 @@ function NewWidget({
|
||||
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(() => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
@@ -557,12 +565,9 @@ function NewWidget({
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(dashboard, {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
onSuccess: () => {
|
||||
setToScrollWidgetId(selectedWidget?.id || '');
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
});
|
||||
navigateToDashboardPage();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
@@ -577,9 +582,8 @@ function NewWidget({
|
||||
preWidgets,
|
||||
updateDashboardMutation,
|
||||
widgets,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
safeNavigate,
|
||||
navigateToDashboardPage,
|
||||
dashboardId,
|
||||
]);
|
||||
|
||||
@@ -588,12 +592,12 @@ function NewWidget({
|
||||
setDiscardModal(true);
|
||||
return;
|
||||
}
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [dashboardId, isQueryModified, safeNavigate]);
|
||||
navigateToDashboardPage();
|
||||
}, [isQueryModified, navigateToDashboardPage]);
|
||||
|
||||
const discardChanges = useCallback(() => {
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [dashboardId, safeNavigate]);
|
||||
navigateToDashboardPage();
|
||||
}, [navigateToDashboardPage]);
|
||||
|
||||
const setGraphHandler = (type: PANEL_TYPES): void => {
|
||||
setIsLoadingPanelData(true);
|
||||
@@ -627,22 +631,25 @@ function NewWidget({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
const isNewPanel = useMemo(() => {
|
||||
const widgetId = query.get('widgetId');
|
||||
const selectWidget = widgets?.find((e) => e.id === widgetId);
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId);
|
||||
return isUndefined(selectedWidget);
|
||||
}, [query, widgets]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
logEvent('Panel Edit: Save changes', {
|
||||
panelType: selectedWidget.panelTypes,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel: isUndefined(selectWidget),
|
||||
isNewPanel,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
});
|
||||
setSaveModal(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [isNewPanel]);
|
||||
|
||||
const isNewTraceLogsAvailable =
|
||||
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
|
||||
@@ -732,12 +739,14 @@ function NewWidget({
|
||||
}
|
||||
const widgetId = query.get('widgetId') || '';
|
||||
const graphType = query.get('graphType') || '';
|
||||
const variables = query.get(QueryParams.variables) || '';
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.graphType]: graphType,
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
[QueryParams.variables]: variables,
|
||||
};
|
||||
|
||||
const updatedSearch = createQueryParams(queryParams);
|
||||
@@ -818,62 +827,62 @@ function NewWidget({
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
setQueryResponse={setQueryResponse}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedDashboard={selectedDashboard}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
</LeftContainerWrapper>
|
||||
|
||||
<RightContainerWrapper>
|
||||
<OverlayScrollbar>
|
||||
<RightContainer
|
||||
setGraphHandler={setGraphHandler}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
stackedBarChart={stackedBarChart}
|
||||
setStackedBarChart={setStackedBarChart}
|
||||
opacity={opacity}
|
||||
yAxisUnit={yAxisUnit}
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
bucketCount={bucketCount}
|
||||
bucketWidth={bucketWidth}
|
||||
combineHistogram={combineHistogram}
|
||||
setCombineHistogram={setCombineHistogram}
|
||||
setBucketWidth={setBucketWidth}
|
||||
setBucketCount={setBucketCount}
|
||||
setOpacity={setOpacity}
|
||||
selectedNullZeroValue={selectedNullZeroValue}
|
||||
setSelectedNullZeroValue={setSelectedNullZeroValue}
|
||||
selectedGraph={graphType}
|
||||
setSelectedTime={setSelectedTime}
|
||||
selectedTime={selectedTime}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
setDecimalPrecision={setDecimalPrecision}
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
selectedWidget={selectedWidget}
|
||||
isFillSpans={isFillSpans}
|
||||
setIsFillSpans={setIsFillSpans}
|
||||
isLogScale={isLogScale}
|
||||
setIsLogScale={setIsLogScale}
|
||||
legendPosition={legendPosition}
|
||||
setLegendPosition={setLegendPosition}
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
softMin={softMin}
|
||||
setSoftMin={setSoftMin}
|
||||
softMax={softMax}
|
||||
setSoftMax={setSoftMax}
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
<RightContainer
|
||||
setGraphHandler={setGraphHandler}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
stackedBarChart={stackedBarChart}
|
||||
setStackedBarChart={setStackedBarChart}
|
||||
opacity={opacity}
|
||||
yAxisUnit={yAxisUnit}
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
bucketCount={bucketCount}
|
||||
bucketWidth={bucketWidth}
|
||||
combineHistogram={combineHistogram}
|
||||
setCombineHistogram={setCombineHistogram}
|
||||
setBucketWidth={setBucketWidth}
|
||||
setBucketCount={setBucketCount}
|
||||
setOpacity={setOpacity}
|
||||
selectedNullZeroValue={selectedNullZeroValue}
|
||||
setSelectedNullZeroValue={setSelectedNullZeroValue}
|
||||
selectedGraph={graphType}
|
||||
setSelectedTime={setSelectedTime}
|
||||
selectedTime={selectedTime}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
setDecimalPrecision={setDecimalPrecision}
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
selectedWidget={selectedWidget}
|
||||
isFillSpans={isFillSpans}
|
||||
setIsFillSpans={setIsFillSpans}
|
||||
isLogScale={isLogScale}
|
||||
setIsLogScale={setIsLogScale}
|
||||
legendPosition={legendPosition}
|
||||
setLegendPosition={setLegendPosition}
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
softMin={softMin}
|
||||
setSoftMin={setSoftMin}
|
||||
softMax={softMax}
|
||||
setSoftMax={setSoftMax}
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
</RightContainerWrapper>
|
||||
</PanelContainer>
|
||||
<Modal
|
||||
|
||||
@@ -15,7 +15,14 @@ export const RightContainerWrapper = styled(Col)`
|
||||
overflow-y: auto;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 0rem;
|
||||
width: 0.3rem;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(136, 136, 136);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { IDashboardContext } from 'providers/Dashboard/types';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -9,9 +10,9 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { timePreferance } from './RightContainer/timeItems';
|
||||
|
||||
export interface NewWidgetProps {
|
||||
dashboardId: string;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
selectedGraph: PANEL_TYPES;
|
||||
yAxisUnit: Widgets['yAxisUnit'];
|
||||
fillSpans: Widgets['fillSpans'];
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,8 @@ export interface WidgetGraphProps {
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export type WidgetGraphContainerProps = {
|
||||
|
||||
@@ -11,11 +11,8 @@ import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
listViewInitialLogQuery,
|
||||
PANEL_TYPES_INITIAL_QUERY,
|
||||
} from 'container/DashboardContainer/ComponentsSlider/constants';
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
@@ -549,10 +546,7 @@ export const getDefaultWidgetData = (
|
||||
nullZeroValues: '',
|
||||
opacity: '',
|
||||
panelTypes: name,
|
||||
query:
|
||||
name === PANEL_TYPES.LIST
|
||||
? listViewInitialLogQuery
|
||||
: PANEL_TYPES_INITIAL_QUERY[name],
|
||||
query: PANEL_TYPES_INITIAL_QUERY[name],
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import { buildHistogramData } from './histogram';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
|
||||
function HistogramPanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
setGraphVisibility,
|
||||
graphVisibility,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<number>(0);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label });
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const histogramData = buildHistogramData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
widget?.bucketWidth,
|
||||
widget?.bucketCount,
|
||||
widget?.mergeAllActiveQueries,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [
|
||||
queryResponse?.data?.payload?.data?.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
const histogramOptions = useMemo(
|
||||
() =>
|
||||
getUplotHistogramChartOptions({
|
||||
id: widget.id,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
histogramData,
|
||||
panelType: widget.panelTypes,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: number) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
containerDimensions,
|
||||
graphVisibility,
|
||||
histogramData,
|
||||
isDarkMode,
|
||||
queryResponse.data?.payload,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
widget.mergeAllActiveQueries,
|
||||
widget.panelTypes,
|
||||
clickHandlerWithContextMenu,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||
<GraphManager
|
||||
data={histogramData}
|
||||
name={widget.id}
|
||||
options={histogramOptions}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphsVisibilityStates={setGraphVisibility}
|
||||
graphsVisibilityStates={graphVisibility}
|
||||
lineChartRef={lineChartRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanelWrapper;
|
||||
@@ -1,4 +0,0 @@
|
||||
.info-text {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert } from 'antd';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
import { getTimeRangeFromStepInterval, isApmMetric } from './utils';
|
||||
|
||||
import './UplotPanelWrapper.styles.scss';
|
||||
|
||||
function UplotPanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
isFullViewMode,
|
||||
setGraphVisibility,
|
||||
graphVisibility,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
onDragSelect,
|
||||
selectedGraph,
|
||||
customTooltipElement,
|
||||
customSeries,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const [hiddenGraph, setHiddenGraph] = useState<{ [key: string]: boolean }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [queryResponse]);
|
||||
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [
|
||||
queryResponse?.data?.payload?.data?.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
);
|
||||
queryResponse.data.payload.data.result = sortedSeriesData;
|
||||
}
|
||||
|
||||
const stackedBarChart = useMemo(
|
||||
() =>
|
||||
(selectedGraph
|
||||
? selectedGraph === PANEL_TYPES.BAR
|
||||
: widget?.panelTypes === PANEL_TYPES.BAR) && widget?.stackedBarChart,
|
||||
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
getUPlotChartData(
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
),
|
||||
[
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget.panelTypes === PANEL_TYPES.BAR && stackedBarChart) {
|
||||
const graphV = cloneDeep(graphVisibility)?.slice(1);
|
||||
const isSomeSelectedLegend = graphV?.some((v) => v === false);
|
||||
if (isSomeSelectedLegend) {
|
||||
const hiddenIndex = graphV?.findIndex((v) => v === true);
|
||||
if (!isUndefined(hiddenIndex) && hiddenIndex !== -1) {
|
||||
const updatedHiddenGraph = { [hiddenIndex]: true };
|
||||
if (!isEqual(hiddenGraph, updatedHiddenGraph)) {
|
||||
setHiddenGraph(updatedHiddenGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [graphVisibility, hiddenGraph, widget.panelTypes, stackedBarChart]);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
xValue,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
// Compute time range if needed and if axes data is available
|
||||
let timeRange;
|
||||
if (axesData && queryData?.queryName) {
|
||||
// Get the compositeQuery from the response params
|
||||
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
|
||||
|
||||
if (compositeQuery?.queries) {
|
||||
// Find the specific query by name from the queries array
|
||||
const specificQuery = compositeQuery.queries.find(
|
||||
(query: any) => query.spec?.name === queryData.queryName,
|
||||
);
|
||||
|
||||
// Use the stepInterval from the specific query, fallback to default
|
||||
const stepInterval = specificQuery?.spec?.stepInterval || 60;
|
||||
timeRange = getTimeRangeFromStepInterval(
|
||||
stepInterval,
|
||||
metric?.clickedTimestamp || xValue, // Use the clicked timestamp if available, otherwise use the click position timestamp
|
||||
specificQuery?.spec?.signal === DataSource.METRICS &&
|
||||
isApmMetric(specificQuery?.spec?.aggregations[0]?.metricName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||
}
|
||||
},
|
||||
[onClick, queryResponse],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
id: widget?.id,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
yAxisUnit: widget?.yAxisUnit,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
thresholds: widget.thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
softMax: widget.softMax === undefined ? null : widget.softMax,
|
||||
softMin: widget.softMin === undefined ? null : widget.softMin,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
panelType: selectedGraph || widget.panelTypes,
|
||||
currentQuery,
|
||||
stackBarChart: stackedBarChart,
|
||||
hiddenGraph,
|
||||
setHiddenGraph,
|
||||
customTooltipElement,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
customSeries,
|
||||
isLogScale: widget?.isLogScale,
|
||||
colorMapping: widget?.customLegendColors,
|
||||
enhancedLegend: true, // Enable enhanced legend
|
||||
legendPosition: widget?.legendPosition,
|
||||
query: widget?.query || currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
decimalPrecision: widget.decimalPrecision,
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
clickHandlerWithContextMenu,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
graphVisibility,
|
||||
setGraphVisibility,
|
||||
selectedGraph,
|
||||
currentQuery,
|
||||
hiddenGraph,
|
||||
customTooltipElement,
|
||||
timezone.value,
|
||||
customSeries,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
widget,
|
||||
stackedBarChart,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{stackedBarChart && isFullViewMode && (
|
||||
<Alert
|
||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||
type="info"
|
||||
className="info-text"
|
||||
/>
|
||||
)}
|
||||
{isFullViewMode && setGraphVisibility && !stackedBarChart && (
|
||||
<GraphManager
|
||||
data={chartData}
|
||||
name={widget.id}
|
||||
options={options}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphsVisibilityStates={setGraphVisibility}
|
||||
graphsVisibilityStates={graphVisibility}
|
||||
lineChartRef={lineChartRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UplotPanelWrapper;
|
||||
@@ -38,6 +38,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.MY_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.NOT_FOUND]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.ORG_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.MEMBERS_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.PASSWORD_RESET]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.SIGN_UP]: [QueryParams.resourceAttributes],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
|
||||
[id: string]: LocalStoreDashboardVariables;
|
||||
}
|
||||
|
||||
interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
export interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
currentDashboard: LocalStoreDashboardVariables;
|
||||
updateLocalStorageDashboardVariables: (
|
||||
id: string,
|
||||
|
||||
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
|
||||
| IDashboardVariable['selectedValue'];
|
||||
}
|
||||
|
||||
interface UseVariablesFromUrlReturn {
|
||||
export interface UseVariablesFromUrlReturn {
|
||||
getUrlVariables: () => LocalStoreDashboardVariables;
|
||||
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
||||
updateUrlVariable: (
|
||||
|
||||
@@ -107,7 +107,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
queryRangeMutation,
|
||||
dashboardVariables,
|
||||
dashboardDynamicVariables,
|
||||
selectedDashboard?.data.version,
|
||||
widget,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ATTRIBUTE_TYPES,
|
||||
initialAutocompleteData,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
listViewInitialLogQuery,
|
||||
listViewInitialTraceQuery,
|
||||
mapOfFormulaToFilters,
|
||||
mapOfQueryFilters,
|
||||
PANEL_TYPES,
|
||||
@@ -23,10 +25,6 @@ import {
|
||||
metricsUnknownSpaceAggregateOperatorOptions,
|
||||
metricsUnknownTimeAggregateOperatorOptions,
|
||||
} from 'constants/queryBuilderOperators';
|
||||
import {
|
||||
listViewInitialLogQuery,
|
||||
listViewInitialTraceQuery,
|
||||
} from 'container/DashboardContainer/ComponentsSlider/constants';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getMetricsOperatorsByAttributeType } from 'lib/newQueryBuilder/getMetricsOperatorsByAttributeType';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
|
||||
@@ -23,10 +23,10 @@ export default {
|
||||
relations: {
|
||||
assignee: ['role'],
|
||||
create: ['metaresources'],
|
||||
delete: ['user', 'role', 'organization', 'metaresource'],
|
||||
delete: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
|
||||
list: ['metaresources'],
|
||||
read: ['user', 'role', 'organization', 'metaresource'],
|
||||
update: ['user', 'role', 'organization', 'metaresource'],
|
||||
read: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
|
||||
update: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
export default DashboardPage;
|
||||
function DashboardPageWithProvider(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
return (
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<DashboardPage />
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageWithProvider;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,101 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { generatePath, useLocation, useParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { generatePath, useParams } from 'react-router-dom';
|
||||
import { Card, Typography } from 'antd';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import NewWidget from 'container/NewWidget';
|
||||
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
function DashboardWidget(): JSX.Element | null {
|
||||
const { search } = useLocation();
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
const { dashboardId } = useParams<{
|
||||
dashboardId: string;
|
||||
}>();
|
||||
const query = useUrlQuery();
|
||||
const { graphType, widgetId } = useMemo(() => {
|
||||
return {
|
||||
graphType: query.get(QueryParams.graphType) as PANEL_TYPES,
|
||||
widgetId: query.get(QueryParams.widgetId),
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const [selectedGraph, setSelectedGraph] = useState<PANEL_TYPES>();
|
||||
|
||||
const { selectedDashboard, dashboardResponse } = useDashboard();
|
||||
|
||||
const params = useUrlQuery();
|
||||
|
||||
const widgetId = params.get('widgetId');
|
||||
const { data } = selectedDashboard || {};
|
||||
const { widgets } = data || {};
|
||||
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId) as Widgets;
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(search);
|
||||
const graphType = params.get('graphType') as PANEL_TYPES | null;
|
||||
|
||||
if (graphType === null) {
|
||||
if (!graphType || !widgetId) {
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
} else {
|
||||
setSelectedGraph(graphType);
|
||||
} else if (!dashboardId) {
|
||||
safeNavigate(ROUTES.HOME);
|
||||
}
|
||||
}, [dashboardId, safeNavigate, search]);
|
||||
}, [graphType, widgetId, dashboardId, safeNavigate]);
|
||||
|
||||
if (selectedGraph === undefined || dashboardResponse.isLoading) {
|
||||
if (!widgetId || !graphType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardWidgetInternal
|
||||
dashboardId={dashboardId}
|
||||
widgetId={widgetId}
|
||||
graphType={graphType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardWidgetInternal({
|
||||
dashboardId,
|
||||
widgetId,
|
||||
graphType,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
widgetId: string;
|
||||
graphType: PANEL_TYPES;
|
||||
}): JSX.Element | null {
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<
|
||||
Dashboard | undefined
|
||||
>(undefined);
|
||||
|
||||
const { transformDashboardVariables } = useTransformDashboardVariables(
|
||||
dashboardId,
|
||||
);
|
||||
|
||||
const {
|
||||
isFetching: isFetchingDashboardResponse,
|
||||
isError: isErrorDashboardResponse,
|
||||
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
|
||||
enabled: true,
|
||||
queryFn: async () =>
|
||||
await getDashboard({
|
||||
id: dashboardId,
|
||||
}),
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
onSuccess: (response) => {
|
||||
const updatedDashboardData = transformDashboardVariables(response.data);
|
||||
setSelectedDashboard(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetchingDashboardResponse) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
if (dashboardResponse.isError) {
|
||||
if (isErrorDashboardResponse) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography>{SOMETHING_WENT_WRONG}</Typography>
|
||||
@@ -54,16 +105,11 @@ function DashboardWidget(): JSX.Element | null {
|
||||
|
||||
return (
|
||||
<NewWidget
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
dashboardId={dashboardId}
|
||||
selectedGraph={graphType}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
selectedDashboard={selectedDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DashboardWidgetPageParams {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export default DashboardWidget;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'mocks-server/__mockdata__/dashboards';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
|
||||
@@ -19,11 +18,6 @@ jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
@@ -47,9 +41,7 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=asgard&order=stones&page=1']}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -71,9 +63,7 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=createdAt&order=descend&page=1']}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -92,9 +82,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -135,9 +123,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -164,9 +150,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -196,9 +180,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ function SettingsPage(): JSX.Element {
|
||||
isAdmin &&
|
||||
(item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.MY_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS)
|
||||
),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const getRoutes = (
|
||||
if (isWorkspaceBlocked && isAdmin) {
|
||||
settings.push(
|
||||
...organizationSettings(t),
|
||||
...membersSettings(t),
|
||||
...mySettings(t),
|
||||
...billingSettings(t),
|
||||
...keyboardShortcuts(t),
|
||||
|
||||
@@ -14,26 +14,21 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, UseQueryResult } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import locked from 'api/v1/dashboards/id/lock';
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
import { defaultTo, isEmpty } from 'lodash-es';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import isUndefined from 'lodash-es/isUndefined';
|
||||
import omitBy from 'lodash-es/omitBy';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -41,10 +36,9 @@ import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
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 { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
@@ -59,9 +53,7 @@ import { IDashboardContext, WidgetColumnWidths } from './types';
|
||||
import { sortLayout } from './util';
|
||||
|
||||
export const DashboardContext = createContext<IDashboardContext>({
|
||||
isDashboardSliderOpen: false,
|
||||
isDashboardLocked: false,
|
||||
handleToggleDashboardSlider: () => {},
|
||||
handleDashboardLockToggle: () => {},
|
||||
dashboardResponse: {} as UseQueryResult<
|
||||
SuccessResponseV2<Dashboard>,
|
||||
@@ -75,8 +67,6 @@ export const DashboardContext = createContext<IDashboardContext>({
|
||||
setLayouts: () => {},
|
||||
setSelectedDashboard: () => {},
|
||||
updatedTimeRef: {} as React.MutableRefObject<Dayjs | null>,
|
||||
toScrollWidgetId: '',
|
||||
setToScrollWidgetId: () => {},
|
||||
updateLocalStorageDashboardVariables: () => {},
|
||||
dashboardQueryRangeCalled: false,
|
||||
setDashboardQueryRangeCalled: () => {},
|
||||
@@ -85,18 +75,11 @@ export const DashboardContext = createContext<IDashboardContext>({
|
||||
setColumnWidths: () => {},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function DashboardProvider({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element {
|
||||
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
|
||||
|
||||
const [toScrollWidgetId, setToScrollWidgetId] = useState<string>('');
|
||||
|
||||
dashboardId,
|
||||
}: PropsWithChildren<{ dashboardId: string }>): JSX.Element {
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false);
|
||||
|
||||
const [
|
||||
@@ -104,11 +87,6 @@ export function DashboardProvider({
|
||||
setDashboardQueryRangeCalled,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const isDashboardPage = useRouteMatch<Props>({
|
||||
path: ROUTES.DASHBOARD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
@@ -119,11 +97,6 @@ export function DashboardProvider({
|
||||
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
const isDashboardWidgetPage = useRouteMatch<Props>({
|
||||
path: ROUTES.DASHBOARD_WIDGET,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const [layouts, setLayouts] = useState<Layout[]>([]);
|
||||
|
||||
const [panelMap, setPanelMap] = useState<
|
||||
@@ -132,11 +105,6 @@ export function DashboardProvider({
|
||||
|
||||
const { isLoggedIn } = useAppContext();
|
||||
|
||||
const dashboardId =
|
||||
(isDashboardPage
|
||||
? isDashboardPage.params.dashboardId
|
||||
: isDashboardWidgetPage?.params.dashboardId) || '';
|
||||
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
|
||||
@@ -161,9 +129,10 @@ export function DashboardProvider({
|
||||
const {
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||
|
||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
transformDashboardVariables,
|
||||
} = useTransformDashboardVariables(dashboardId);
|
||||
|
||||
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
|
||||
const modalRef = useRef<any>(null);
|
||||
@@ -175,108 +144,14 @@ export function DashboardProvider({
|
||||
|
||||
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(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
isDashboardPage?.params,
|
||||
dashboardId,
|
||||
globalTime.isAutoRefreshDisabled,
|
||||
],
|
||||
{
|
||||
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
|
||||
enabled: !!dashboardId && isLoggedIn,
|
||||
queryFn: async () => {
|
||||
setIsDashboardFetching(true);
|
||||
try {
|
||||
@@ -299,13 +174,14 @@ export function DashboardProvider({
|
||||
},
|
||||
|
||||
onSuccess: (data: SuccessResponseV2<Dashboard>) => {
|
||||
// if the url variable is not set for any variable, set it to the default value
|
||||
const variables = data?.data?.data?.variables;
|
||||
const updatedDashboardData = transformDashboardVariables(data?.data);
|
||||
|
||||
// initialize URL variables after dashboard state is set to avoid race conditions
|
||||
const variables = updatedDashboardData?.data?.variables;
|
||||
if (variables) {
|
||||
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
||||
}
|
||||
|
||||
const updatedDashboardData = transformDashboardVariables(data?.data);
|
||||
const updatedDate = dayjs(updatedDashboardData?.updatedAt);
|
||||
|
||||
setIsDashboardLocked(updatedDashboardData?.locked || false);
|
||||
@@ -396,11 +272,7 @@ export function DashboardProvider({
|
||||
|
||||
useEffect(() => {
|
||||
// make the call on tab visibility only if the user is on dashboard / widget page
|
||||
if (
|
||||
isVisible &&
|
||||
updatedTimeRef.current &&
|
||||
(!!isDashboardPage || !!isDashboardWidgetPage)
|
||||
) {
|
||||
if (isVisible && updatedTimeRef.current && !!dashboardId) {
|
||||
dashboardResponse.refetch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -412,13 +284,8 @@ export function DashboardProvider({
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
const handleToggleDashboardSlider = (value: boolean): void => {
|
||||
setIsDashboardSlider(value);
|
||||
};
|
||||
|
||||
const { mutate: lockDashboard } = useMutation(locked, {
|
||||
onSuccess: (_, props) => {
|
||||
setIsDashboardSlider(false);
|
||||
setIsDashboardLocked(props.lock);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -443,10 +310,7 @@ export function DashboardProvider({
|
||||
|
||||
const value: IDashboardContext = useMemo(
|
||||
() => ({
|
||||
toScrollWidgetId,
|
||||
isDashboardSliderOpen,
|
||||
isDashboardLocked,
|
||||
handleToggleDashboardSlider,
|
||||
handleDashboardLockToggle,
|
||||
dashboardResponse,
|
||||
selectedDashboard,
|
||||
@@ -457,7 +321,6 @@ export function DashboardProvider({
|
||||
setPanelMap,
|
||||
setSelectedDashboard,
|
||||
updatedTimeRef,
|
||||
setToScrollWidgetId,
|
||||
updateLocalStorageDashboardVariables,
|
||||
dashboardQueryRangeCalled,
|
||||
setDashboardQueryRangeCalled,
|
||||
@@ -467,14 +330,12 @@ export function DashboardProvider({
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
isDashboardSliderOpen,
|
||||
isDashboardLocked,
|
||||
dashboardResponse,
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
layouts,
|
||||
panelMap,
|
||||
toScrollWidgetId,
|
||||
updateLocalStorageDashboardVariables,
|
||||
currentDashboard,
|
||||
dashboardQueryRangeCalled,
|
||||
|
||||
@@ -2,11 +2,10 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import { DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -19,30 +18,28 @@ jest.mock('api/v1/dashboards/id/get');
|
||||
jest.mock('api/v1/dashboards/id/lock');
|
||||
const mockGetDashboard = jest.mocked(getDashboard);
|
||||
|
||||
// Mock useRouteMatch to simulate different route scenarios
|
||||
const mockUseRouteMatch = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: (): any => mockUseRouteMatch(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock only the essential dependencies for Dashboard provider
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): any => ({
|
||||
useAppContext: (): {
|
||||
isLoggedIn: boolean;
|
||||
user: { email: string; role: string };
|
||||
} => ({
|
||||
isLoggedIn: true,
|
||||
user: { email: 'test@example.com', role: 'ADMIN' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): any => ({ showErrorModal: jest.fn() }),
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
@@ -60,11 +57,10 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
function TestComponent(): JSX.Element {
|
||||
const { dashboardResponse, selectedDashboard } = useDashboard();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = selectedDashboard?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="dashboard-id">{dashboardId}</div>
|
||||
<div data-testid="dashboard-id">{selectedDashboard?.id}</div>
|
||||
<div data-testid="query-status">{dashboardResponse.status}</div>
|
||||
<div data-testid="is-loading">{dashboardResponse.isLoading.toString()}</div>
|
||||
<div data-testid="is-fetching">
|
||||
@@ -94,27 +90,15 @@ function createTestQueryClient(): QueryClient {
|
||||
|
||||
// Helper to render with dashboard provider
|
||||
function renderWithDashboardProvider(
|
||||
initialRoute = '/dashboard/test-dashboard-id',
|
||||
routeMatchParams?: { dashboardId: string } | null,
|
||||
): any {
|
||||
dashboardId = 'test-dashboard-id',
|
||||
): RenderResult {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
// Mock the route match
|
||||
mockUseRouteMatch.mockReturnValue(
|
||||
routeMatchParams
|
||||
? {
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${routeMatchParams.dashboardId}`,
|
||||
isExact: true,
|
||||
params: routeMatchParams,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const initialRoute = dashboardId ? `/dashboard/${dashboardId}` : '/dashboard';
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -188,7 +172,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
describe('Query Key Behavior', () => {
|
||||
it('should include route params in query key when on dashboard page', async () => {
|
||||
const dashboardId = 'test-dashboard-id';
|
||||
renderWithDashboardProvider(`/dashboard/${dashboardId}`, { dashboardId });
|
||||
renderWithDashboardProvider(dashboardId);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId });
|
||||
@@ -203,30 +187,17 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
const newDashboardId = 'new-dashboard-id';
|
||||
|
||||
// First render with initial dashboard ID
|
||||
const { rerender } = renderWithDashboardProvider(
|
||||
`/dashboard/${initialDashboardId}`,
|
||||
{
|
||||
dashboardId: initialDashboardId,
|
||||
},
|
||||
);
|
||||
const { rerender } = renderWithDashboardProvider(initialDashboardId);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: initialDashboardId });
|
||||
});
|
||||
|
||||
// Change route params to simulate navigation
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${newDashboardId}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: newDashboardId },
|
||||
});
|
||||
|
||||
// Rerender with new route
|
||||
// Rerender with new dashboard ID prop
|
||||
rerender(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${newDashboardId}`]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={newDashboardId}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -241,50 +212,24 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not fetch when not on dashboard page', () => {
|
||||
// Mock no route match (not on dashboard page)
|
||||
mockUseRouteMatch.mockReturnValue(null);
|
||||
|
||||
renderWithDashboardProvider('/some-other-page', null);
|
||||
it('should not fetch when no dashboardId is provided', () => {
|
||||
renderWithDashboardProvider('');
|
||||
|
||||
// Should not call the API
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined route params gracefully', async () => {
|
||||
// Mock route match with undefined params
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: '/dashboard/undefined',
|
||||
isExact: true,
|
||||
params: undefined,
|
||||
});
|
||||
|
||||
renderWithDashboardProvider('/dashboard/undefined');
|
||||
|
||||
// Should not call API when params are undefined
|
||||
expect(mockGetDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Behavior', () => {
|
||||
it('should create separate cache entries for different route params', async () => {
|
||||
it('should create separate cache entries for different dashboardIds', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const dashboardId1 = 'dashboard-1';
|
||||
const dashboardId2 = 'dashboard-2';
|
||||
|
||||
// First dashboard
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${dashboardId1}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: dashboardId1 },
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${dashboardId1}`]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={dashboardId1}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -295,18 +240,10 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId1 });
|
||||
});
|
||||
|
||||
// Second dashboard
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${dashboardId2}`,
|
||||
isExact: true,
|
||||
params: { dashboardId: dashboardId2 },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${dashboardId2}`]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={dashboardId2}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -325,13 +262,11 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
expect(cacheKeys).toHaveLength(2);
|
||||
expect(cacheKeys[0]).toEqual([
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
{ dashboardId: dashboardId1 },
|
||||
dashboardId1,
|
||||
true, // globalTime.isAutoRefreshDisabled
|
||||
]);
|
||||
expect(cacheKeys[1]).toEqual([
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
{ dashboardId: dashboardId2 },
|
||||
dashboardId2,
|
||||
true, // globalTime.isAutoRefreshDisabled
|
||||
]);
|
||||
@@ -348,17 +283,10 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const dashboardId = 'auto-refresh-dashboard';
|
||||
|
||||
mockUseRouteMatch.mockReturnValue({
|
||||
path: ROUTES.DASHBOARD,
|
||||
url: `/dashboard/${dashboardId}`,
|
||||
isExact: true,
|
||||
params: { dashboardId },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/dashboard/${dashboardId}`]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<TestComponent />
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>
|
||||
@@ -375,7 +303,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
|
||||
.find(
|
||||
(query) =>
|
||||
query.queryKey[0] === REACT_QUERY_KEY.DASHBOARD_BY_ID &&
|
||||
query.queryKey[3] === false,
|
||||
query.queryKey[2] === false,
|
||||
);
|
||||
expect(dashboardQuery).toBeDefined();
|
||||
expect((dashboardQuery as { cacheTime: number }).cacheTime).toBe(
|
||||
@@ -437,9 +365,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
// Empty URL variables - tests initialization flow
|
||||
mockGetUrlVariables.mockReturnValue({});
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -455,6 +381,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
multiSelect: false,
|
||||
allSelected: false,
|
||||
showALLOption: true,
|
||||
order: 0,
|
||||
},
|
||||
services: {
|
||||
id: 'svc-id',
|
||||
@@ -462,6 +389,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
multiSelect: true,
|
||||
allSelected: false,
|
||||
showALLOption: true,
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
mockGetUrlVariables,
|
||||
@@ -493,9 +421,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['db', 'cache']);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -555,9 +481,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
|
||||
mockGetUrlVariables.mockReturnValue(urlVariables);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -593,9 +517,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['api']);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify normalization was called with the specific values and variable configs
|
||||
@@ -662,9 +584,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -706,9 +626,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -751,9 +669,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -795,9 +711,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
renderWithDashboardProvider(DASHBOARD_ID);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ScrollToWidgetIdState {
|
||||
toScrollWidgetId: string;
|
||||
setToScrollWidgetId: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
export const useScrollToWidgetIdStore = create<ScrollToWidgetIdState>(
|
||||
(set) => ({
|
||||
toScrollWidgetId: '',
|
||||
setToScrollWidgetId: (widgetId): void => set({ toScrollWidgetId: widgetId }),
|
||||
}),
|
||||
);
|
||||
@@ -9,9 +9,7 @@ export type WidgetColumnWidths = {
|
||||
};
|
||||
|
||||
export interface IDashboardContext {
|
||||
isDashboardSliderOpen: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
handleToggleDashboardSlider: (value: boolean) => void;
|
||||
handleDashboardLockToggle: (value: boolean) => void;
|
||||
dashboardResponse: UseQueryResult<SuccessResponseV2<Dashboard>, unknown>;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
@@ -23,8 +21,6 @@ export interface IDashboardContext {
|
||||
React.SetStateAction<Dashboard | undefined>
|
||||
>;
|
||||
updatedTimeRef: React.MutableRefObject<dayjs.Dayjs | null>;
|
||||
toScrollWidgetId: string;
|
||||
setToScrollWidgetId: React.Dispatch<React.SetStateAction<string>>;
|
||||
updateLocalStorageDashboardVariables: (
|
||||
id: string,
|
||||
selectedValue:
|
||||
|
||||
9
frontend/src/utils/maskedKey.ts
Normal file
9
frontend/src/utils/maskedKey.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Masks a key string, showing only the first 2 and last 2 characters.
|
||||
*/
|
||||
export function getMaskedKey(key: string): string {
|
||||
if (!key || key.length < 4) {
|
||||
return key || 'N/A';
|
||||
}
|
||||
return `${key.substring(0, 2)}·······${key.slice(-2).trim()}`;
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -11,7 +11,6 @@ require (
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.2
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/bytedance/sonic v1.14.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.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/smithy-go v1.24.0 // 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/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
|
||||
@@ -72,7 +72,7 @@ func (service *Service) SyncServers(ctx context.Context) error {
|
||||
|
||||
service.serversMtx.Lock()
|
||||
for _, org := range orgs {
|
||||
config, err := service.getConfig(ctx, org.ID.StringValue())
|
||||
config, _, err := service.getConfig(ctx, org.ID.StringValue())
|
||||
if err != nil {
|
||||
service.settings.Logger().ErrorContext(ctx, "failed to get alertmanager config for org", "org_id", org.ID.StringValue(), "error", err)
|
||||
continue
|
||||
@@ -171,7 +171,7 @@ func (service *Service) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (service *Service) newServer(ctx context.Context, orgID string) (*alertmanagerserver.Server, error) {
|
||||
config, err := service.getConfig(ctx, orgID)
|
||||
config, storedHash, err := service.getConfig(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,13 +181,16 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beforeCompareAndSelectHash := config.StoreableConfig().Hash
|
||||
config, err = service.compareAndSelectConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if beforeCompareAndSelectHash == config.StoreableConfig().Hash {
|
||||
// compare against the hash of the config stored in the DB (before overlays
|
||||
// were applied by getConfig). This ensures that overlay changes (e.g. new
|
||||
// defaults from an upstream upgrade or something similar) trigger a DB update
|
||||
// so that other code paths reading directly from the store see the up-to-date config.
|
||||
if storedHash == config.StoreableConfig().Hash {
|
||||
service.settings.Logger().DebugContext(ctx, "skipping config store update for org", "org_id", orgID, "hash", config.StoreableConfig().Hash)
|
||||
return server, nil
|
||||
}
|
||||
@@ -200,27 +203,33 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (service *Service) getConfig(ctx context.Context, orgID string) (*alertmanagertypes.Config, error) {
|
||||
// getConfig returns the config for the given orgID with overlays applied, along
|
||||
// with the hash that was stored in the DB before overlays. When no config exists
|
||||
// in the store yet the stored hash is empty.
|
||||
func (service *Service) getConfig(ctx context.Context, orgID string) (*alertmanagertypes.Config, string, error) {
|
||||
config, err := service.configStore.Get(ctx, orgID)
|
||||
var storedHash string
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
config, err = alertmanagertypes.NewDefaultConfig(service.config.Global, service.config.Route, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
} else {
|
||||
storedHash = config.StoreableConfig().Hash
|
||||
}
|
||||
|
||||
if err := config.SetGlobalConfig(service.config.Global); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if err := config.SetRouteConfig(service.config.Route); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
return config, storedHash, nil
|
||||
}
|
||||
|
||||
func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConfig *alertmanagertypes.Config) (*alertmanagertypes.Config, error) {
|
||||
|
||||
@@ -2,9 +2,11 @@ module base
|
||||
|
||||
type user
|
||||
|
||||
type serviceaccount
|
||||
|
||||
type role
|
||||
relations
|
||||
define assignee: [user]
|
||||
define assignee: [user, serviceaccount]
|
||||
|
||||
type organisation
|
||||
relations
|
||||
|
||||
@@ -78,7 +78,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
|
||||
// add the paths that are not promoted but have indexes
|
||||
for path, indexes := range aggr {
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix)
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
|
||||
path = telemetrytypes.BodyJSONStringSearchPrefix + path
|
||||
response = append(response, promotetypes.PromotePath{
|
||||
Path: path,
|
||||
@@ -163,7 +163,7 @@ func (m *module) PromoteAndIndexPaths(
|
||||
}
|
||||
}
|
||||
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 _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
|
||||
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn
|
||||
|
||||
@@ -111,7 +111,12 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount.Update(req.Name, req.Email, req.Roles)
|
||||
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Update(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -147,7 +152,12 @@ func (handler *handler) UpdateStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount.UpdateStatus(req.Status)
|
||||
err = serviceAccount.UpdateStatus(req.Status)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateStatus(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -290,7 +300,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
factorAPIKey.Update(req.Name, req.ExpiresAt)
|
||||
err = handler.module.UpdateFactorAPIKey(ctx, serviceAccount.ID, factorAPIKey)
|
||||
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -33,7 +34,7 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
|
||||
}
|
||||
|
||||
// authz actions cannot run in sql transactions
|
||||
err = module.authz.Grant(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, serviceAccount.ID.String(), orgID, nil))
|
||||
err = module.authz.Grant(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -60,6 +61,24 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
existingServiceAccount, err := module.store.GetActiveByOrgIDAndName(ctx, serviceAccount.OrgID, serviceAccount.Name)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingServiceAccount != nil {
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
@@ -138,7 +157,7 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
|
||||
|
||||
// gets the role diff if any to modify grants.
|
||||
grants, revokes := serviceAccount.PatchRoles(input)
|
||||
err = module.authz.ModifyGrant(ctx, orgID, revokes, grants, authtypes.MustNewSubject(authtypes.TypeableUser, serviceAccount.ID.String(), orgID, nil))
|
||||
err = module.authz.ModifyGrant(ctx, orgID, revokes, grants, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -171,26 +190,28 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
|
||||
}
|
||||
|
||||
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, input.ID)
|
||||
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if input.Status == serviceAccount.Status {
|
||||
return nil
|
||||
}
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch input.Status {
|
||||
case serviceaccounttypes.StatusActive:
|
||||
err := module.activateServiceAccount(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case serviceaccounttypes.StatusDisabled:
|
||||
err := module.disableServiceAccount(ctx, orgID, input)
|
||||
// update the status but do not delete the role mappings as we will use them for audits
|
||||
err = module.store.Update(ctx, orgID, serviceaccounttypes.NewStorableServiceAccount(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -203,7 +224,7 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
}
|
||||
|
||||
// revoke from authz first as this cannot run in sql transaction
|
||||
err = module.authz.Revoke(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, serviceAccount.ID.String(), orgID, nil))
|
||||
err = module.authz.Revoke(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -276,8 +297,13 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
|
||||
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
return module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -307,45 +333,3 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) disableServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the status but do not delete the role mappings as we will reuse them on activation.
|
||||
err = module.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) activateServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Grant(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,6 +48,25 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Where("status = ?", serviceaccounttypes.StatusActive).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with name: %s doesn't exist in org: %s", name, orgID.String())
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
@@ -188,7 +207,7 @@ func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceacc
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountFactorAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -206,7 +225,7 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccounFactorAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
|
||||
@@ -15,6 +15,9 @@ type Module interface {
|
||||
// Gets a service account by id.
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets or creates a service account by name
|
||||
GetOrCreate(context.Context, *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets a service account by id without fetching roles.
|
||||
GetWithoutRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
@@ -40,7 +43,7 @@ type Module interface {
|
||||
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Updates an existing API key for a service account
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
|
||||
|
||||
// Revokes an existing API key for a service account
|
||||
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
@@ -10,13 +10,11 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
type builderQuery[T any] struct {
|
||||
@@ -262,40 +260,6 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
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{
|
||||
Type: q.kind,
|
||||
Value: payload,
|
||||
@@ -423,18 +387,3 @@ func decodeCursor(cur string) (int64, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -394,17 +393,11 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
val := reflect.ValueOf(cellPtr).Elem().Interface()
|
||||
|
||||
// Post-process JSON columns: normalize into structured values
|
||||
// Post-process JSON columns: normalize into String value
|
||||
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
|
||||
switch x := val.(type) {
|
||||
case []byte:
|
||||
if len(x) > 0 {
|
||||
var v any
|
||||
if err := sonic.Unmarshal(x, &v); err == nil {
|
||||
val = v
|
||||
}
|
||||
}
|
||||
val = string(x)
|
||||
default:
|
||||
// already a structured type (map[string]any, []any, etc.)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
value = fmt.Sprintf("%t", v)
|
||||
}
|
||||
|
||||
case telemetrytypes.FieldDataTypeInt64,
|
||||
telemetrytypes.FieldDataTypeArrayInt64,
|
||||
telemetrytypes.FieldDataTypeNumber,
|
||||
|
||||
@@ -313,37 +313,30 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
return ""
|
||||
}
|
||||
child := ctx.GetChild(0)
|
||||
var searchText string
|
||||
if keyCtx, ok := child.(*grammar.KeyContext); ok {
|
||||
// create a full text search condition on the body field
|
||||
|
||||
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
|
||||
searchText = keyCtx.GetText()
|
||||
} else if valCtx, ok := child.(*grammar.ValueContext); ok {
|
||||
var text string
|
||||
if valCtx.QUOTED_TEXT() != nil {
|
||||
text = trimQuotes(valCtx.QUOTED_TEXT().GetText())
|
||||
searchText = trimQuotes(valCtx.QUOTED_TEXT().GetText())
|
||||
} else if valCtx.NUMBER() != nil {
|
||||
text = valCtx.NUMBER().GetText()
|
||||
searchText = valCtx.NUMBER().GetText()
|
||||
} else if valCtx.BOOL() != nil {
|
||||
text = valCtx.BOOL().GetText()
|
||||
searchText = valCtx.BOOL().GetText()
|
||||
} else if valCtx.KEY() != nil {
|
||||
text = valCtx.KEY().GetText()
|
||||
searchText = valCtx.KEY().GetText()
|
||||
} else {
|
||||
v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText()))
|
||||
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
|
||||
@@ -383,6 +376,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
for _, key := range keys {
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
|
||||
return ""
|
||||
}
|
||||
conds = append(conds, condition)
|
||||
@@ -648,7 +642,6 @@ func (v *filterExpressionVisitor) VisitValueList(ctx *grammar.ValueListContext)
|
||||
|
||||
// VisitFullText handles standalone quoted strings for full-text search
|
||||
func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
|
||||
|
||||
if v.skipFullTextFilter {
|
||||
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()))
|
||||
return ""
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,12 @@ package telemetrylogs
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
@@ -54,14 +52,14 @@ func (c *conditionBuilder) conditionFor(
|
||||
}
|
||||
|
||||
// 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 = querybuilder.DataTypeCollisionHandledFieldName(key, value, tblFieldName, operator)
|
||||
|
||||
// make use of case insensitive index for body
|
||||
if tblFieldName == "body" {
|
||||
if tblFieldName == "body" || tblFieldName == "body_v2.message" {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorLike:
|
||||
return sb.ILike(tblFieldName, value), nil
|
||||
@@ -108,7 +106,6 @@ func (c *conditionBuilder) conditionFor(
|
||||
return sb.ILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
return sb.NotILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil
|
||||
|
||||
case qbtypes.FilterOperatorRegexp:
|
||||
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
|
||||
// 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:
|
||||
if operator == qbtypes.FilterOperatorExists {
|
||||
return sb.IsNotNull(tblFieldName), nil
|
||||
} else {
|
||||
return sb.IsNull(tblFieldName), nil
|
||||
}
|
||||
return sb.IsNull(tblFieldName), nil
|
||||
case schema.ColumnTypeEnumLowCardinality:
|
||||
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
case schema.ColumnTypeEnumString:
|
||||
@@ -247,19 +243,30 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !(key.FieldContext == telemetrytypes.FieldContextBody && querybuilder.BodyJSONQueryEnabled) && operator.AddDefaultExistsFilter() {
|
||||
// skip adding exists filter for intrinsic fields
|
||||
// with an exception for body json search
|
||||
field, _ := c.fm.FieldFor(ctx, key)
|
||||
if slices.Contains(maps.Keys(IntrinsicFields), field) && key.FieldContext != telemetrytypes.FieldContextBody {
|
||||
// Skip adding exists filter for intrinsic fields i.e. Table level log context fields
|
||||
buildExistCondition := operator.AddDefaultExistsFilter()
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextScope:
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
if buildExistCondition {
|
||||
existsCondition, err := c.conditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sb.And(condition, existsCondition), nil
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
@@ -127,7 +127,8 @@ func TestConditionFor(t *testing.T) {
|
||||
{
|
||||
name: "Contains operator - body",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "body",
|
||||
Name: "body",
|
||||
FieldContext: telemetrytypes.FieldContextLog,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorContains,
|
||||
value: 521509198310,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package telemetrylogs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/constants"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
@@ -17,7 +20,7 @@ const (
|
||||
LogsV2TimestampColumn = "timestamp"
|
||||
LogsV2ObservedTimestampColumn = "observed_timestamp"
|
||||
LogsV2BodyColumn = "body"
|
||||
LogsV2BodyJSONColumn = constants.BodyV2Column
|
||||
LogsV2BodyV2Column = constants.BodyV2Column
|
||||
LogsV2BodyPromotedColumn = constants.BodyPromotedColumn
|
||||
LogsV2TraceIDColumn = "trace_id"
|
||||
LogsV2SpanIDColumn = "span_id"
|
||||
@@ -34,8 +37,13 @@ const (
|
||||
LogsV2ResourcesStringColumn = "resources_string"
|
||||
LogsV2ScopeStringColumn = "scope_string"
|
||||
|
||||
BodyJSONColumnPrefix = constants.BodyV2ColumnPrefix
|
||||
BodyV2ColumnPrefix = constants.BodyV2ColumnPrefix
|
||||
BodyPromotedColumnPrefix = constants.BodyPromotedColumnPrefix
|
||||
|
||||
// messageSubColumn is the ClickHouse sub-column that body searches map to
|
||||
// when BodyJSONQueryEnabled is true.
|
||||
messageSubColumn = "body_v2.message"
|
||||
bodySearchDefaultWarning = "body searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -118,3 +126,11 @@ var (
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func bodyAliasExpression() string {
|
||||
if !querybuilder.BodyJSONQueryEnabled {
|
||||
return LogsV2BodyColumn
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s as body", LogsV2BodyV2Column)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ var (
|
||||
"severity_text": {Name: "severity_text", Type: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}},
|
||||
"severity_number": {Name: "severity_number", Type: schema.ColumnTypeUInt8},
|
||||
"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)),
|
||||
MaxDynamicPaths: utils.ToPointer(uint(0)),
|
||||
}},
|
||||
@@ -88,21 +89,23 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
|
||||
return logsV2Columns["attributes_bool"], nil
|
||||
}
|
||||
case telemetrytypes.FieldContextBody:
|
||||
// Body context is for JSON body fields
|
||||
// Use body_json if feature flag is enabled
|
||||
// Body context is for JSON body fields. Use body_v2 if feature flag is enabled.
|
||||
if querybuilder.BodyJSONQueryEnabled {
|
||||
return logsV2Columns[LogsV2BodyJSONColumn], nil
|
||||
return logsV2Columns[LogsV2BodyV2Column], nil
|
||||
}
|
||||
// Fall back to legacy body column
|
||||
return logsV2Columns["body"], nil
|
||||
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
|
||||
if key.Name == LogsV2BodyColumn && querybuilder.BodyJSONQueryEnabled {
|
||||
return logsV2Columns[messageSubColumn], nil
|
||||
}
|
||||
col, ok := logsV2Columns[key.Name]
|
||||
if !ok {
|
||||
// check if the key has body JSON search
|
||||
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 {
|
||||
return logsV2Columns[LogsV2BodyJSONColumn], nil
|
||||
return logsV2Columns[LogsV2BodyV2Column], nil
|
||||
}
|
||||
// Fall back to legacy body column
|
||||
return logsV2Columns["body"], nil
|
||||
@@ -246,34 +249,37 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
|
||||
node := plan[0]
|
||||
|
||||
expr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
|
||||
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)
|
||||
}
|
||||
// TODO(Piyush): Promoted path logic commented out. Materialized now means type hint
|
||||
// promotion will be extracted from key field evolution
|
||||
// (direct sub-column access), not a promoted body_promoted.* column.
|
||||
// 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]
|
||||
promotedExpr := fmt.Sprintf(
|
||||
"dynamicElement(%s, '%s')",
|
||||
node.FieldPath(),
|
||||
node.TerminalConfig.ElemType.StringValue(),
|
||||
)
|
||||
// node := plan[1]
|
||||
// promotedExpr := fmt.Sprintf(
|
||||
// "dynamicElement(%s, '%s')",
|
||||
// node.FieldPath(),
|
||||
// node.TerminalConfig.ElemType.StringValue(),
|
||||
// )
|
||||
|
||||
// dynamicElement returns NULL for scalar types or an empty array for array types.
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
expr = fmt.Sprintf(
|
||||
"if(length(%s) > 0, %s, %s)",
|
||||
promotedExpr,
|
||||
promotedExpr,
|
||||
expr,
|
||||
)
|
||||
} else {
|
||||
// promoted column first then body_json column
|
||||
// TODO(Piyush): Change this in future for better performance
|
||||
expr = fmt.Sprintf("coalesce(%s, %s)", promotedExpr, expr)
|
||||
}
|
||||
// // dynamicElement returns NULL for scalar types or an empty array for array types.
|
||||
// if node.TerminalConfig.ElemType.IsArray {
|
||||
// expr = fmt.Sprintf(
|
||||
// "if(length(%s) > 0, %s, %s)",
|
||||
// promotedExpr,
|
||||
// promotedExpr,
|
||||
// expr,
|
||||
// )
|
||||
// } else {
|
||||
// // promoted column first then body_json column
|
||||
// // TODO(Piyush): Change this in future for better performance
|
||||
// expr = fmt.Sprintf("coalesce(%s, %s)", promotedExpr, expr)
|
||||
// }
|
||||
|
||||
}
|
||||
// }
|
||||
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
|
||||
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) {
|
||||
conditions := []string{}
|
||||
for _, node := range c.key.JSONPlan {
|
||||
@@ -40,6 +40,7 @@ func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperato
|
||||
}
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
@@ -288,9 +289,9 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
|
||||
}
|
||||
return sb.NotIn(fieldExpr, values...), nil
|
||||
case qbtypes.FilterOperatorExists:
|
||||
return fmt.Sprintf("%s IS NOT NULL", fieldExpr), nil
|
||||
return sb.IsNotNull(fieldExpr), nil
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("%s IS NULL", fieldExpr), nil
|
||||
return sb.IsNull(fieldExpr), nil
|
||||
// between and not between
|
||||
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
|
||||
values, ok := value.([]any)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user