mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 08:30:35 +01:00
Compare commits
5 Commits
feat/event
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56b98dab4 | ||
|
|
820334c548 | ||
|
|
b1e026e640 | ||
|
|
38bce69ce9 | ||
|
|
02a7082b9e |
69
.github/CODEOWNERS
vendored
69
.github/CODEOWNERS
vendored
@@ -199,72 +199,3 @@ go.mod @therealpandey
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
## Logs
|
||||
/frontend/src/pages/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogControls/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
|
||||
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/logs/ @SigNoz/events-frontend
|
||||
|
||||
## Logs Pipelines
|
||||
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
|
||||
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
|
||||
|
||||
## Traces / Trace Explorer
|
||||
/frontend/src/pages/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/container/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Funnels
|
||||
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Details
|
||||
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/trace/ @SigNoz/events-frontend
|
||||
|
||||
## Exceptions
|
||||
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/container/AllError/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
|
||||
|
||||
## External APIs
|
||||
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
|
||||
|
||||
## Messaging Queues
|
||||
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
@@ -659,29 +659,6 @@ components:
|
||||
refreshToken:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesPostableUser:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
frontendBaseUrl:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
- userRoles
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
- create
|
||||
@@ -10206,7 +10183,7 @@ paths:
|
||||
- global
|
||||
/api/v1/invite:
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint creates an invite for a user
|
||||
operationId: CreateInvite
|
||||
requestBody:
|
||||
@@ -10269,7 +10246,7 @@ paths:
|
||||
- users
|
||||
/api/v1/invite/bulk:
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint creates a bulk invite for a user
|
||||
operationId: CreateBulkInvite
|
||||
requestBody:
|
||||
@@ -13110,7 +13087,7 @@ paths:
|
||||
- tracedetail
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint lists all users
|
||||
operationId: ListUsersDeprecated
|
||||
responses:
|
||||
@@ -13203,7 +13180,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint returns the user by id
|
||||
operationId: GetUserDeprecated
|
||||
parameters:
|
||||
@@ -13260,7 +13237,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint updates the user by id
|
||||
operationId: UpdateUserDeprecated
|
||||
parameters:
|
||||
@@ -13329,7 +13306,7 @@ paths:
|
||||
- users
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint returns the user I belong to
|
||||
operationId: GetMyUserDeprecated
|
||||
responses:
|
||||
@@ -20745,68 +20722,6 @@ paths:
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a user for the organization
|
||||
operationId: CreateUser
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUser'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create user
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -98,15 +98,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
Active: aiObservability,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -2258,32 +2258,6 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frontendBaseUrl?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles: AuthtypesPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10833,14 +10807,6 @@ export type ListUsers200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUser201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -18,11 +18,9 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
DeleteUserPathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
@@ -171,7 +169,6 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const createInvite = (
|
||||
@@ -233,7 +230,6 @@ export type CreateInviteMutationBody =
|
||||
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const useCreateInvite = <
|
||||
@@ -256,7 +252,6 @@ export const useCreateInvite = <
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a bulk invite for a user
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const createBulkInvite = (
|
||||
@@ -318,7 +313,6 @@ export type CreateBulkInviteMutationBody =
|
||||
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const useCreateBulkInvite = <
|
||||
@@ -424,7 +418,6 @@ export const useResetPassword = <
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all users
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const listUsersDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -470,7 +463,6 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
|
||||
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
|
||||
@@ -494,7 +486,6 @@ export function useListUsersDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const invalidateListUsersDeprecated = async (
|
||||
@@ -590,7 +581,6 @@ export const useDeleteUser = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const getUserDeprecated = (
|
||||
@@ -650,7 +640,6 @@ export type GetUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
|
||||
@@ -677,7 +666,6 @@ export function useGetUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const invalidateGetUserDeprecated = async (
|
||||
@@ -695,7 +683,6 @@ export const invalidateGetUserDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint updates the user by id
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const updateUserDeprecated = (
|
||||
@@ -768,7 +755,6 @@ export type UpdateUserDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const useUpdateUserDeprecated = <
|
||||
@@ -797,7 +783,6 @@ export const useUpdateUserDeprecated = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user I belong to
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const getMyUserDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -843,7 +828,6 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
|
||||
@@ -867,7 +851,6 @@ export function useGetMyUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const invalidateGetMyUserDeprecated = async (
|
||||
@@ -1226,89 +1209,6 @@ export const invalidateListUsers = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a user for the organization
|
||||
* @summary Create user
|
||||
*/
|
||||
export const createUser = (
|
||||
authtypesPostableUserDTO?: BodyType<AuthtypesPostableUserDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUser201>({
|
||||
url: `/api/v2/users`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUser'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUser(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUser>>
|
||||
>;
|
||||
export type CreateUserMutationBody =
|
||||
| BodyType<AuthtypesPostableUserDTO>
|
||||
| undefined;
|
||||
export type CreateUserMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user
|
||||
*/
|
||||
export const useCreateUser = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @summary Get user by user id
|
||||
|
||||
@@ -12,5 +12,4 @@ export enum FeatureKeys {
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
}
|
||||
|
||||
@@ -43,5 +43,4 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
|
||||
import { Select } from 'antd';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isRetryableError } from 'utils/errorUtils';
|
||||
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import {
|
||||
DYNAMIC_SIGNAL_LABEL,
|
||||
DYNAMIC_SIGNALS,
|
||||
type DynamicSignalOption,
|
||||
signalForApi,
|
||||
} from '../variableFormModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: TelemetrySignal;
|
||||
signal: DynamicSignalOption;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
dynamicSignal?: DynamicSignalOption;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
|
||||
attributeError?: string;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
@@ -27,18 +37,24 @@ function DynamicVariableFields({
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
const {
|
||||
data: keyData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetFieldKeys({
|
||||
signal: apiSignal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
// CustomSelect filters the supplied options locally as the user types.
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
@@ -49,7 +65,7 @@ function DynamicVariableFields({
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal,
|
||||
signal: apiSignal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
@@ -62,40 +78,60 @@ function DynamicVariableFields({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
const errorMessage = error ? (error as Error).message || null : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<SelectSimple
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
testId="variable-signal-select"
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
filterOption={isComplete}
|
||||
options={options}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
noDataMessage="No fields found"
|
||||
errorMessage={errorMessage}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{attributeError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
|
||||
import { Select } from 'antd';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
|
||||
import {
|
||||
VARIABLE_SORT_LABEL,
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from '../variableFormModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface ListVariableFieldsProps {
|
||||
model: VariableFormModel;
|
||||
onChange: (patch: Partial<VariableFormModel>) => void;
|
||||
previewValues: (string | number)[];
|
||||
previewError: string | null;
|
||||
defaultValue: string;
|
||||
onDefaultValueChange: (value: string) => void;
|
||||
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
|
||||
showAllOptionField: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
|
||||
* preview, sort, multi-select / ALL toggles and the default-value picker.
|
||||
*/
|
||||
function ListVariableFields({
|
||||
model,
|
||||
onChange,
|
||||
previewValues,
|
||||
previewError,
|
||||
defaultValue,
|
||||
onDefaultValueChange,
|
||||
showAllOptionField,
|
||||
}: ListVariableFieldsProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={model.sort}
|
||||
options={VARIABLE_SORTS.map((sort) => ({
|
||||
label: VARIABLE_SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => onChange({ sort: value as VariableSort })}
|
||||
data-testid="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void =>
|
||||
onChange({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
})
|
||||
}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => onChange({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Default Value</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListVariableFields;
|
||||
@@ -3,14 +3,14 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
sort: VariableSort;
|
||||
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
|
||||
variables: PayloadVariables;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
@@ -19,7 +19,7 @@ interface QueryVariableFieldsProps {
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
sort,
|
||||
variables,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
@@ -30,20 +30,21 @@ function QueryVariableFields({
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
const res = await dashboardVariablesQuery({ query: queryValue, variables });
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
onPreview(res.payload.variableValues ?? []);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
|
||||
const detail = (err as { details?: { error?: string } }).details?.error;
|
||||
const message =
|
||||
detail && detail.includes('Syntax error:')
|
||||
? 'Please make sure query is valid and dependent variables are selected'
|
||||
: detail || (err as Error).message || 'Failed to run query';
|
||||
onError(message);
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
|
||||
@@ -5,22 +5,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -42,6 +28,12 @@
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sourceLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
@@ -59,7 +51,7 @@
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
@@ -78,48 +70,89 @@
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type segmented group */
|
||||
/* Variable type — Tabs root composing the picker row + per-type body panels. */
|
||||
.typeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Picker row (label left, tabs right); the bottom divider separates type from
|
||||
config. Single line — the tab row scrolls (never wraps) when narrow. */
|
||||
.typePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
|
||||
.typePanel {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.typeContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
|
||||
is hidden — the row stays a single crisp line and scrolls only when narrow. */
|
||||
.typeTabsScroll {
|
||||
justify-self: flex-end;
|
||||
--tab-list-wrapper-secondary-padding-left: 0;
|
||||
}
|
||||
|
||||
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
|
||||
padding, segments divided by 1px borders, active segment filled + bold. */
|
||||
.typeTabs {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
.typeTab {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
&[data-state='active'] {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
// override the Tabs component's default (transparent) active background.
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
@@ -138,7 +171,7 @@
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -154,7 +187,7 @@
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
@@ -208,7 +241,7 @@
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -271,13 +304,9 @@
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
@@ -297,14 +326,21 @@
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 100%;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,350 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, Check, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { TabsContent, TabsRoot } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput } from 'antd';
|
||||
|
||||
import {
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import type { VariableType } from '../variableFormModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
import ListVariableFields from './ListVariableFields';
|
||||
import QueryVariableFields from './QueryVariableFields';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import { useVariableForm } from './useVariableForm';
|
||||
import VariableTypeTabs from './VariableTypeTabs';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** Names of the other variables, for uniqueness validation. */
|
||||
existingNames: string[];
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
import BackToAllVariables from '../components/BackToAllVariables/BackToAllVariables';
|
||||
import { VariableFormProps } from '../types';
|
||||
import VariableInfoForm from '../components/VariableInfoForm/VariableInfoForm';
|
||||
|
||||
/**
|
||||
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
|
||||
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
|
||||
* and searchable selects). Master→detail: renders in place of the list.
|
||||
* and searchable selects). Master→detail: renders in place of the list. Form
|
||||
* state/handlers live in {@link useVariableForm}; the shared list-type rows in
|
||||
* {@link ListVariableFields}.
|
||||
*/
|
||||
function VariableForm({
|
||||
initial,
|
||||
existingNames,
|
||||
siblings,
|
||||
isNew,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
const {
|
||||
model,
|
||||
set,
|
||||
onNameChange,
|
||||
selectType,
|
||||
onCustomChange,
|
||||
onDynamicChange,
|
||||
setRawPreview,
|
||||
previewValues,
|
||||
previewError,
|
||||
setPreviewError,
|
||||
defaultValue,
|
||||
setDefaultValue,
|
||||
visibleNameError,
|
||||
nameError,
|
||||
attributeError,
|
||||
cycleError,
|
||||
isListType,
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
} = useVariableForm({ initial, siblings, isNew, onSave });
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
setDefaultValue(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
});
|
||||
};
|
||||
// Shared list rows (preview/sort/multi/default) for the list-type variables;
|
||||
// rendered as a sibling inside each list-type panel. Only the active panel
|
||||
// mounts (Tabs unmounts the rest), so reusing one element is safe.
|
||||
const listFields = isListType ? (
|
||||
<ListVariableFields
|
||||
model={model}
|
||||
onChange={set}
|
||||
previewValues={previewValues}
|
||||
previewError={previewError}
|
||||
defaultValue={defaultValue}
|
||||
onDefaultValueChange={setDefaultValue}
|
||||
showAllOptionField={showAllOptionField}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.allVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.allVariablesBtn}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<BackToAllVariables onClose={onClose} />
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Name */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Name</Typography.Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={model.name}
|
||||
placeholder="Unique name of the variable"
|
||||
onChange={(e): void => set({ name: e.target.value })}
|
||||
testId="variable-name-input"
|
||||
/>
|
||||
{nameError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{nameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<VariableInfoForm
|
||||
title={model.name}
|
||||
description={model.description}
|
||||
onTitleChange={onNameChange}
|
||||
onDescriptionChange={(value): void => set({ description: value })}
|
||||
visibleNameError={visibleNameError}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textarea}
|
||||
value={model.description}
|
||||
placeholder="Enter a description for the variable"
|
||||
rows={3}
|
||||
onChange={(e): void => set({ description: e.target.value })}
|
||||
data-testid="variable-description-input"
|
||||
/>
|
||||
</div>
|
||||
<TabsRoot
|
||||
className={styles.typeSection}
|
||||
value={model.type}
|
||||
onValueChange={(next): void => selectType(next as VariableType)}
|
||||
>
|
||||
<VariableTypeTabs />
|
||||
|
||||
{/* Variable Type */}
|
||||
<VariableTypeSelector value={model.type} onChange={selectType} />
|
||||
|
||||
{/* Type-specific body */}
|
||||
{model.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={(patch): void => set(patch)}
|
||||
onPreview={setPreviewValues}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'QUERY' ? (
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
sort={model.sort}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setPreviewValues}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'CUSTOM' ? (
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
<TabsContent value="DYNAMIC" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={onDynamicChange}
|
||||
onPreview={setRawPreview}
|
||||
attributeError={attributeError}
|
||||
/>
|
||||
{listFields}
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{model.type === 'TEXT' ? (
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
<TabsContent value="QUERY" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
variables={payloadVariables}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setRawPreview}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
{listFields}
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* Shared rows for list-type variables */}
|
||||
{isListType ? (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={model.sort}
|
||||
items={VARIABLE_SORTS.map((sort) => ({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
<TabsContent value="CUSTOM" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void => {
|
||||
set({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
});
|
||||
}}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => set({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<TabsContent value="TEXT" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => setDefaultValue(value ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
|
||||
{cycleError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{cycleError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError || !!attributeError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { VariableType } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface VariableTypeSelectorProps {
|
||||
value: VariableType;
|
||||
onChange: (type: VariableType) => void;
|
||||
}
|
||||
|
||||
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
|
||||
function VariableTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: VariableTypeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.row, styles.typeSection)}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.typeBtnGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<Pyramid size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'DYNAMIC',
|
||||
})}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<ClipboardType size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'TEXT',
|
||||
})}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutList size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'CUSTOM',
|
||||
})}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<DatabaseZap size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'QUERY',
|
||||
})}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeSelector;
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational trigger row for the variable-type tabs (label + segmented
|
||||
* triggers). Must render inside a `TabsRoot`, which owns the active state and
|
||||
* change handling; the matching `TabsContent` panels are siblings in the root.
|
||||
*/
|
||||
function VariableTypeTabs(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.typePicker}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.typeTabsScroll}>
|
||||
<TabsList variant="secondary" className={styles.typeTabs}>
|
||||
<TabsTrigger
|
||||
value="DYNAMIC"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
<Pyramid size={14} />
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="TEXT"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
<ClipboardType size={14} />
|
||||
Textbox
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="CUSTOM"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
<LayoutList size={14} />
|
||||
Custom
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="QUERY"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
<DatabaseZap size={14} />
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
<span
|
||||
className={styles.betaTag}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<TextToolTip
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeTabs;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { detectVariableCycle } from '../variableDependencies';
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../variableFormModel';
|
||||
import { getAttributeError, getNameError } from './variableValidation';
|
||||
|
||||
// Stable reference so the zustand selector never returns a fresh object (which
|
||||
// would make useSyncExternalStore loop) when this dashboard has no selections.
|
||||
const EMPTY_SELECTIONS: VariableSelectionMap = {};
|
||||
|
||||
interface UseVariableFormArgs {
|
||||
initial: VariableFormModel;
|
||||
siblings: VariableFormModel[];
|
||||
isNew: boolean;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
export interface UseVariableForm {
|
||||
model: VariableFormModel;
|
||||
set: (patch: Partial<VariableFormModel>) => void;
|
||||
onNameChange: (value: string) => void;
|
||||
selectType: (type: VariableType) => void;
|
||||
onCustomChange: (value: string) => void;
|
||||
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
|
||||
setRawPreview: (values: (string | number)[]) => void;
|
||||
previewValues: (string | number)[];
|
||||
previewError: string | null;
|
||||
setPreviewError: (message: string | null) => void;
|
||||
defaultValue: string;
|
||||
setDefaultValue: (value: string) => void;
|
||||
visibleNameError: string | null;
|
||||
nameError: string | null;
|
||||
attributeError: string | undefined;
|
||||
cycleError: string | null;
|
||||
isListType: boolean;
|
||||
showAllOptionField: boolean;
|
||||
payloadVariables: PayloadVariables;
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
onSave,
|
||||
}: UseVariableFormArgs): UseVariableForm {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
|
||||
// preview re-sorts when Sort changes.
|
||||
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [cycleError, setCycleError] = useState<string | null>(null);
|
||||
// In add mode, mirror the chosen attribute into the name until the user types.
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
readDefaultValue(initial),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setRawPreview([]);
|
||||
setPreviewError(null);
|
||||
setCycleError(null);
|
||||
setNameTouched(false);
|
||||
setDefaultValue(readDefaultValue(initial));
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const previewValues = useMemo(
|
||||
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
|
||||
[rawPreview, model.sort],
|
||||
);
|
||||
|
||||
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
|
||||
|
||||
const existingDynamicAttributes = useMemo(
|
||||
() =>
|
||||
siblings
|
||||
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
|
||||
.map((v) => v.dynamicAttribute),
|
||||
[siblings],
|
||||
);
|
||||
|
||||
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const selections = useDashboardStore(
|
||||
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
|
||||
);
|
||||
const payloadVariables = useMemo<PayloadVariables>(() => {
|
||||
const out: PayloadVariables = {};
|
||||
siblings.forEach((v) => {
|
||||
if (v.name) {
|
||||
out[v.name] = selections[v.name]?.value ?? null;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [siblings, selections]);
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames, initial.name);
|
||||
// Surface the message only once the field is dirty; Save stays disabled regardless.
|
||||
const visibleNameError = nameTouched ? nameError : null;
|
||||
const attributeError = getAttributeError(model, existingDynamicAttributes);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const onNameChange = (value: string): void => {
|
||||
setNameTouched(true);
|
||||
set({ name: value });
|
||||
};
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setRawPreview([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setRawPreview(commaValuesParser(value));
|
||||
};
|
||||
|
||||
// In add mode, mirror the selected attribute into the name until the user
|
||||
// edits the name themselves (matches the V1 dynamic-variable behaviour).
|
||||
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
|
||||
if (isNew && !nameTouched && patch.dynamicAttribute) {
|
||||
set({ ...patch, name: patch.dynamicAttribute });
|
||||
} else {
|
||||
set(patch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
const next: VariableFormModel = {
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
};
|
||||
|
||||
const cycle = detectVariableCycle([...siblings, next]);
|
||||
if (cycle) {
|
||||
setCycleError(
|
||||
`Cannot save: circular dependency detected between variables: ${cycle.join(
|
||||
' → ',
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCycleError(null);
|
||||
onSave(next);
|
||||
};
|
||||
|
||||
return {
|
||||
model,
|
||||
set,
|
||||
onNameChange,
|
||||
selectType,
|
||||
onCustomChange,
|
||||
onDynamicChange,
|
||||
setRawPreview,
|
||||
previewValues,
|
||||
previewError,
|
||||
setPreviewError,
|
||||
defaultValue,
|
||||
setDefaultValue,
|
||||
visibleNameError,
|
||||
nameError,
|
||||
attributeError,
|
||||
cycleError,
|
||||
isListType,
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { VariableFormModel } from '../variableFormModel';
|
||||
|
||||
/**
|
||||
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
|
||||
* set includes self, but keeping your own (original) name is always allowed.
|
||||
*/
|
||||
export function getNameError(
|
||||
name: string,
|
||||
existingNames: string[],
|
||||
originalName: string,
|
||||
): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (name !== originalName && existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
|
||||
export function getAttributeError(
|
||||
model: VariableFormModel,
|
||||
existingDynamicAttributes: string[],
|
||||
): string | undefined {
|
||||
if (
|
||||
model.type === 'DYNAMIC' &&
|
||||
model.dynamicAttribute &&
|
||||
existingDynamicAttributes.includes(model.dynamicAttribute)
|
||||
) {
|
||||
return 'A variable with this attribute key already exists';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariableRowProps {
|
||||
variable: VariableFormModel;
|
||||
index: number;
|
||||
canEdit: boolean;
|
||||
/** True when this row's delete is awaiting inline confirmation. */
|
||||
isConfirmingDelete: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
}
|
||||
|
||||
/** A single draggable variable row (drag handle + meta + inline actions). */
|
||||
function VariableRow({
|
||||
variable,
|
||||
index,
|
||||
canEdit,
|
||||
isConfirmingDelete,
|
||||
onEdit,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: variable.name });
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.row}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
{canEdit ? (
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
className={styles.dragHandle}
|
||||
aria-label="Reorder variable"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
) : null}
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && !isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableRow;
|
||||
@@ -2,13 +2,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -30,14 +28,6 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -62,6 +52,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PenLine,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import VariableRow from './VariableRow';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariablesListProps {
|
||||
variables: VariableFormModel[];
|
||||
canEdit: boolean;
|
||||
@@ -41,98 +37,48 @@ function VariablesList({
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = variables.findIndex((v) => v.name === active.id);
|
||||
const to = variables.findIndex((v) => v.name === over.id);
|
||||
if (from !== -1 && to !== -1) {
|
||||
onMove(from, to);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
key={variable.name || `variable-${index}`}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && confirmingIndex === index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && confirmingIndex !== index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={(): void => onMove(index, index - 1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === variables.length - 1}
|
||||
onClick={(): void => onMove(index, index + 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
const AddVariableButton = ({
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
setIsEditing: (state: { type: 'new' }) => void;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
disabled={!isEditable}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddVariableButton;
|
||||
@@ -0,0 +1,11 @@
|
||||
.backToAllVariables {
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l3-border);
|
||||
}
|
||||
|
||||
.backToAllVariablesButton {
|
||||
--button-font-size: 14px;
|
||||
--button-padding: var(--spacing-5) var(--spacing-3);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import styles from './BackToAllVariables.module.scss';
|
||||
import { VariableFormProps } from '../../types';
|
||||
|
||||
const BackToAllVariables = ({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: VariableFormProps['onClose'];
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.backToAllVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.backToAllVariablesButton}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
size="md"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackToAllVariables;
|
||||
@@ -0,0 +1,25 @@
|
||||
.noVariablesCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.noVariablesCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.noVariablesTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.noVariablesInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddVariableButton from '../AddVariableButton';
|
||||
import { EditingState } from '../../types';
|
||||
import styles from './NoVariables.module.scss';
|
||||
|
||||
const NoVariablesCard = ({
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.noVariablesCard}>
|
||||
<div className={styles.noVariablesCopy}>
|
||||
<Typography.Text className={styles.noVariablesTitle}>
|
||||
No variables yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.noVariablesInfo}>
|
||||
Create a variable to parameterize your panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoVariablesCard;
|
||||
@@ -0,0 +1,25 @@
|
||||
.infoItemContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.variableNameInput {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import styles from './VariableInfoForm.module.scss';
|
||||
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
|
||||
|
||||
interface VariableInfoFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
visibleNameError: string | null;
|
||||
}
|
||||
|
||||
function VariableInfoForm({
|
||||
title,
|
||||
description,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
visibleNameError,
|
||||
}: VariableInfoFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Name</Typography>
|
||||
|
||||
<Input
|
||||
testId="variable-name"
|
||||
className={styles.variableNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
placeholder="Unique name of the variable"
|
||||
/>
|
||||
|
||||
{visibleNameError ? (
|
||||
<Typography.Text className={variableFormStyles.errorText}>
|
||||
<sup>*</sup>
|
||||
{visibleNameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Description</Typography>
|
||||
<AntdInput.TextArea
|
||||
className={styles.descriptionTextArea}
|
||||
value={description}
|
||||
placeholder="Enter a description for the variable"
|
||||
data-testid="dashboard-desc"
|
||||
rows={3}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableInfoForm;
|
||||
@@ -1,75 +1,75 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableModel';
|
||||
} from './variableFormModel';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
type EditingState = { index: number | null } | null;
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialModels = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
const initialFormModels = useMemo(
|
||||
() => dashboard.spec.variables.map(dtoToFormModel),
|
||||
[dashboard.spec.variables],
|
||||
);
|
||||
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
|
||||
const [variables, setVariables] =
|
||||
useState<VariableFormModel[]>(initialFormModels);
|
||||
|
||||
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
|
||||
useEffect(() => {
|
||||
setVariables(initialModels);
|
||||
setVariables(initialFormModels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [editing, setEditing] = useState<EditingState>(null);
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editingModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!editing) {
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
return null;
|
||||
}
|
||||
return editing.index === null
|
||||
return isEditing.type === 'new'
|
||||
? emptyVariableFormModel()
|
||||
: variables[editing.index];
|
||||
}, [editing, variables]);
|
||||
: variables[isEditing.index];
|
||||
}, [isEditing, variables]);
|
||||
|
||||
const existingNames = useMemo(() => {
|
||||
const self = editing?.index ?? null;
|
||||
return variables.filter((_, i) => i !== self).map((v) => v.name);
|
||||
}, [variables, editing]);
|
||||
const siblings = useMemo(() => {
|
||||
const self = isEditing?.type === 'edit' ? isEditing.index : null;
|
||||
return variables.filter((_, i) => i !== self);
|
||||
}, [variables, isEditing]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (model: VariableFormModel): void => {
|
||||
const handleFormSave = (Formmodel: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (editing?.index == null) {
|
||||
next.push(model);
|
||||
} else {
|
||||
next[editing.index] = model;
|
||||
if (isEditing?.type === 'new') {
|
||||
next.push(Formmodel);
|
||||
} else if (isEditing?.type === 'edit') {
|
||||
next[isEditing.index] = Formmodel;
|
||||
}
|
||||
setEditing(null);
|
||||
setIsEditing(null);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
@@ -88,14 +88,14 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
// Detail view — edit/new form replaces the list in place (no modal).
|
||||
if (editingModel) {
|
||||
if (editingFormModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
initial={editingModel}
|
||||
existingNames={existingNames}
|
||||
initial={editingFormModel}
|
||||
siblings={siblings}
|
||||
isNew={isEditing?.type === 'new'}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setEditing(null)}
|
||||
onClose={(): void => setIsEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
);
|
||||
@@ -103,42 +103,25 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
|
||||
// Master view — the variables list.
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Typography.Text className={styles.title}>Variables</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Define variables to parameterize panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setEditing({ index: null })}
|
||||
testId="add-variable"
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.container, settingsStyles.settingsCard)}>
|
||||
{variables.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text>No variables defined yet.</Typography.Text>
|
||||
</div>
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
) : (
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setEditing({ index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { VariableFormModel } from './variableFormModel';
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
export type EditingState =
|
||||
| { type: 'new' }
|
||||
| { type: 'edit'; index: number }
|
||||
| null;
|
||||
|
||||
export interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** The other variables (excluding this one), for uniqueness & cycle checks. */
|
||||
siblings: VariableFormModel[];
|
||||
/** True when adding a new variable (enables auto-naming from the attribute). */
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
|
||||
interface UseSaveVariables {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
@@ -14,21 +13,24 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
type DynamicSignalOption,
|
||||
emptyVariableFormModel,
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
signalForApi,
|
||||
VARIABLE_SORT_DISABLED,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableModel';
|
||||
} from './variableFormModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
export function dtoToFormModel(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel {
|
||||
const base = emptyVariableFormModel();
|
||||
const display = dto.spec?.display;
|
||||
const display = dto.spec.display;
|
||||
const common: VariableFormModel = {
|
||||
...base,
|
||||
// TODO
|
||||
name: dto.spec?.name ?? display?.name ?? '',
|
||||
description: display?.description ?? '',
|
||||
};
|
||||
@@ -50,7 +52,7 @@ export function dtoToFormModel(
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
@@ -67,7 +69,9 @@ export function dtoToFormModel(
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
|
||||
// An omitted wire signal means "all telemetry".
|
||||
dynamicSignal:
|
||||
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
@@ -95,7 +99,7 @@ function buildPlugin(
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
|
||||
signal: signalForApi(model.dynamicSignal),
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
@@ -114,7 +118,6 @@ export function formModelToDto(
|
||||
const display = {
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
hidden: model.hidden,
|
||||
};
|
||||
|
||||
if (model.type === 'TEXT') {
|
||||
@@ -135,7 +138,10 @@ export function formModelToDto(
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
allowAllValue: model.showAllOption,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
|
||||
// which forced showALLOption true on save); other types respect the toggle.
|
||||
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
|
||||
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
plugin: buildPlugin(model),
|
||||
@@ -149,5 +155,3 @@ export function variableTypeOf(
|
||||
): VariableFormModel['type'] {
|
||||
return dtoToFormModel(dto).type;
|
||||
}
|
||||
|
||||
export { PLUGIN_KIND };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
|
||||
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
|
||||
/**
|
||||
* Detects a circular reference among QUERY variables (a query referencing
|
||||
* another that, transitively, references it back). Reuses the V1 dependency
|
||||
* graph helpers, which key off `name` / `type` / `queryValue` only.
|
||||
*
|
||||
* Returns the names forming the cycle, or `null` when the set is acyclic.
|
||||
*/
|
||||
export function detectVariableCycle(
|
||||
variables: VariableFormModel[],
|
||||
): string[] | null {
|
||||
const asDbVariables = variables
|
||||
.filter((variable) => variable.name)
|
||||
.map(
|
||||
(variable) =>
|
||||
({
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
queryValue: variable.queryValue,
|
||||
}) as IDashboardVariable,
|
||||
);
|
||||
|
||||
const { hasCycle, cycleNodes } = buildDependencyGraph(
|
||||
buildDependencies(asDbVariables),
|
||||
);
|
||||
|
||||
return hasCycle ? (cycleNodes ?? []) : null;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortBy } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* The four variable types the editor exposes. No generated enum exists for this
|
||||
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
|
||||
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
|
||||
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
|
||||
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
|
||||
* ever exposes a single variable-kind type.
|
||||
*/
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
/** Telemetry signal — the generated enum (traces / logs / metrics). */
|
||||
export type TelemetrySignal = TelemetrytypesSignalDTO;
|
||||
|
||||
/**
|
||||
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
|
||||
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
|
||||
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
|
||||
*/
|
||||
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
|
||||
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
|
||||
|
||||
/**
|
||||
* Sort order for list-variable values. The wire (Perses) validates `sort`
|
||||
* against a fixed method set. There is no generated TS enum for it
|
||||
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
|
||||
* so we mirror the Perses `Sort` tokens here.
|
||||
*/
|
||||
export const VARIABLE_SORT = {
|
||||
DISABLED: 'none',
|
||||
ASC: 'alphabetical-asc',
|
||||
DESC: 'alphabetical-desc',
|
||||
NUMERICAL_ASC: 'numerical-asc',
|
||||
NUMERICAL_DESC: 'numerical-desc',
|
||||
CI_ASC: 'alphabetical-ci-asc',
|
||||
CI_DESC: 'alphabetical-ci-desc',
|
||||
} as const;
|
||||
|
||||
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
|
||||
|
||||
/** Persisted "no sort" value (Perses `none`). */
|
||||
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = [
|
||||
VARIABLE_SORT.DISABLED,
|
||||
VARIABLE_SORT.ASC,
|
||||
VARIABLE_SORT.DESC,
|
||||
VARIABLE_SORT.NUMERICAL_ASC,
|
||||
VARIABLE_SORT.NUMERICAL_DESC,
|
||||
VARIABLE_SORT.CI_ASC,
|
||||
VARIABLE_SORT.CI_DESC,
|
||||
];
|
||||
|
||||
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
|
||||
[VARIABLE_SORT.DISABLED]: 'Disabled',
|
||||
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
|
||||
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
|
||||
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
|
||||
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
|
||||
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
|
||||
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
|
||||
};
|
||||
|
||||
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
];
|
||||
|
||||
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
|
||||
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
|
||||
[TelemetrytypesSignalDTO.traces]: 'Traces',
|
||||
[TelemetrytypesSignalDTO.logs]: 'Logs',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
|
||||
};
|
||||
|
||||
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
|
||||
export function signalForApi(
|
||||
signal: DynamicSignalOption,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
switch (sort) {
|
||||
case VARIABLE_SORT.ASC:
|
||||
return sortBy(values);
|
||||
case VARIABLE_SORT.DESC:
|
||||
return sortBy(values).reverse();
|
||||
case VARIABLE_SORT.NUMERICAL_ASC:
|
||||
return sortBy(values, (value) => Number(value));
|
||||
case VARIABLE_SORT.NUMERICAL_DESC:
|
||||
return sortBy(values, (value) => Number(value)).reverse();
|
||||
case VARIABLE_SORT.CI_ASC:
|
||||
return sortBy(values, (value) => String(value).toLowerCase());
|
||||
case VARIABLE_SORT.CI_DESC:
|
||||
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: VARIABLE_SORT_DISABLED,
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
|
||||
* to bind a form to; `variableAdapters` converts between this model and the DTO.
|
||||
*/
|
||||
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
|
||||
|
||||
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
/** Wire `kind` discriminators (string values of the generated enums). */
|
||||
export const ENVELOPE_KIND = {
|
||||
LIST: 'ListVariable',
|
||||
TEXT: 'TextVariable',
|
||||
} as const;
|
||||
|
||||
export const PLUGIN_KIND = {
|
||||
QUERY: 'signoz/QueryVariable',
|
||||
CUSTOM: 'signoz/CustomVariable',
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
'logs',
|
||||
'metrics',
|
||||
];
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
hidden: false,
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Tooltip } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import DynamicSelector from './selectors/DynamicSelector';
|
||||
import QuerySelector from './selectors/QuerySelector';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
|
||||
function formatQueryValue(val: string): string {
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import {
|
||||
signalForApi,
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
|
||||
@@ -6,8 +6,8 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import DashboardsList from './components/DashboardsList/DashboardsList';
|
||||
import DashboardsList from './components/DashboardsList';
|
||||
|
||||
import styles from './DashboardsListPageV2.module.scss';
|
||||
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
|
||||
|
||||
function DashboardsListPageV2(): JSX.Element {
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
@@ -24,7 +24,8 @@ function DashboardsListPageV2(): JSX.Element {
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>
|
||||
<LayoutGrid size={14} className={styles.icon} />
|
||||
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
|
||||
</div>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Popover } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
Copy,
|
||||
Expand,
|
||||
EllipsisVertical,
|
||||
Link2,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -40,23 +31,6 @@ function ActionsPopover({
|
||||
onView,
|
||||
}: Props): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
const { mutate: runClone, isLoading: isCloning } = useMutation({
|
||||
mutationFn: () => cloneDashboardV2({ id: dashboardId }),
|
||||
onSuccess: (response) => {
|
||||
toast.success(`Duplicated "${dashboardName}"`);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -97,20 +71,6 @@ function ActionsPopover({
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<DeleteActionItem
|
||||
dashboardId={dashboardId}
|
||||
dashboardName={dashboardName}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
padding: 12px 14.634px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 7.317px;
|
||||
border-radius: 4px;
|
||||
border: 0.915px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.previewIcon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18.293px;
|
||||
letter-spacing: -0.064px;
|
||||
}
|
||||
|
||||
.previewDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.previewRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.formattedTime {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.formattedTimeText {
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16.463px;
|
||||
letter-spacing: -0.064px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.userTag {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: normal;
|
||||
letter-spacing: -0.05px;
|
||||
border-radius: 12.805px;
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
.userLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16.463px;
|
||||
letter-spacing: -0.064px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0px 0px 0px 14.634px;
|
||||
}
|
||||
|
||||
.actionLeft {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connectionLine {
|
||||
border-top: 1px dashed var(--l1-border);
|
||||
min-width: 20px;
|
||||
flex-grow: 1;
|
||||
margin: 0px 8px;
|
||||
}
|
||||
|
||||
.actionRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saveChanges {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 8px 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
:global(.configureMetadataModalRoot) {
|
||||
:global(.ant-modal-content) {
|
||||
width: 500px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--card);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-header) {
|
||||
background: var(--card);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-footer) {
|
||||
margin-top: 0px;
|
||||
padding: 4px 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { CalendarClock, Check, Clock4 } from '@signozhq/icons';
|
||||
import { get } from 'lodash-es';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { lastUpdatedLabel, type DashboardListItem } from '../../utils';
|
||||
import {
|
||||
DynamicColumns,
|
||||
useDashboardsListVisibleColumnsStore,
|
||||
type DashboardDynamicColumns,
|
||||
} from './useDynamicColumns';
|
||||
|
||||
import styles from './ConfigureMetadataModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
previewDashboard: DashboardListItem | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConfigureMetadataModal({
|
||||
open,
|
||||
previewDashboard,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const storedColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
const setStoredColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.setVisibleColumns,
|
||||
);
|
||||
const [draftColumns, setDraftColumns] =
|
||||
useState<DashboardDynamicColumns>(storedColumns);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraftColumns(storedColumns);
|
||||
}
|
||||
}, [open, storedColumns]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
setStoredColumns(draftColumns);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const previewImage = previewDashboard?.image || Base64Icons[0];
|
||||
const previewName = previewDashboard?.spec?.display?.name;
|
||||
const previewCreatedBy = previewDashboard?.createdBy;
|
||||
const previewUpdatedBy = previewDashboard?.updatedBy;
|
||||
const previewUpdatedAt = previewDashboard?.updatedAt;
|
||||
|
||||
const formattedCreatedAt = previewDashboard
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
get(previewDashboard, 'createdAt', '') as string,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="Configure Metadata"
|
||||
footer={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Check size={14} />}
|
||||
className={styles.saveChanges}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
}
|
||||
rootClassName="configureMetadataModalRoot"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.preview}>
|
||||
<section className={styles.previewHeader}>
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="dashboard-image"
|
||||
className={styles.previewIcon}
|
||||
/>
|
||||
<Typography.Text className={styles.previewTitle}>
|
||||
{previewName}
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.previewDetails}>
|
||||
<section className={styles.previewRow}>
|
||||
{draftColumns.createdAt && (
|
||||
<span className={styles.formattedTime}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.formattedTimeText}>
|
||||
{formattedCreatedAt}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
{draftColumns.createdBy && (
|
||||
<div className={styles.user}>
|
||||
<Typography.Text className={styles.userTag}>
|
||||
{previewCreatedBy?.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.userLabel}>
|
||||
{previewCreatedBy}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.previewRow}>
|
||||
{draftColumns.updatedAt && (
|
||||
<span className={styles.formattedTime}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.formattedTimeText}>
|
||||
{lastUpdatedLabel(previewUpdatedAt)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
{draftColumns.updatedBy && (
|
||||
<div className={styles.user}>
|
||||
<Typography.Text className={styles.userTag}>
|
||||
{previewUpdatedBy?.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.userLabel}>
|
||||
{previewUpdatedBy}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>Created at</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value
|
||||
disabled
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.CREATED_AT]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>Created by</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value
|
||||
disabled
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.CREATED_BY]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<Clock4 size={14} />
|
||||
<Typography.Text>Updated at</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value={draftColumns.updatedAt}
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.UPDATED_AT]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<Clock4 size={14} />
|
||||
<Typography.Text>Updated by</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value={draftColumns.updatedBy}
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.UPDATED_BY]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureMetadataModal;
|
||||
@@ -0,0 +1,34 @@
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.templatesItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.textButton {
|
||||
display: flex;
|
||||
width: 153px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.createDashboardMenuOverlay) {
|
||||
width: 200px;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Dropdown to @signozhq/ui/dropdown-menu
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
ExternalLink,
|
||||
Github,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Radius,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './CreateDashboardDropdown.module.scss';
|
||||
|
||||
interface Props {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
onImportJSON: () => void;
|
||||
variant?: 'primary' | 'text';
|
||||
}
|
||||
|
||||
const TEMPLATES_HREF =
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
|
||||
|
||||
function CreateDashboardDropdown({
|
||||
canCreate,
|
||||
onCreate,
|
||||
onImportJSON,
|
||||
variant = 'primary',
|
||||
}: Props): JSX.Element {
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'import-json',
|
||||
label: (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
data-testid="import-json-menu-cta"
|
||||
onClick={onImportJSON}
|
||||
>
|
||||
<Radius size={14} /> Import JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'view-templates',
|
||||
label: (
|
||||
<a
|
||||
href={TEMPLATES_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="view-templates-menu-cta"
|
||||
>
|
||||
<div className={styles.templatesItem}>
|
||||
<div className={styles.menuItem}>
|
||||
<Github size={14} /> View templates
|
||||
</div>
|
||||
<ExternalLink size={14} />
|
||||
</div>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (canCreate) {
|
||||
menuItems.unshift({
|
||||
key: 'create-dashboard',
|
||||
label: (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
data-testid="create-dashboard-menu-cta"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}, [canCreate, onCreate, onImportJSON]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="createDashboardMenuOverlay"
|
||||
menu={{ items }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
{variant === 'primary' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
className={cx('periscope-btn primary', styles.primaryButton)}
|
||||
icon={<Plus size={14} />}
|
||||
data-testid="new-dashboard-cta"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.textButton}
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateDashboardDropdown;
|
||||
@@ -1,14 +1,9 @@
|
||||
.row {
|
||||
padding: 12px 16px 16px 16px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
background: var(--l1-background);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--l2-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.titleWithAction {
|
||||
@@ -62,40 +57,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.favBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.row:hover .favBtn {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.favBtn:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.favBtnOn {
|
||||
color: var(--bg-amber-500);
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { CalendarClock, Star } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
@@ -12,7 +11,6 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
@@ -37,12 +35,6 @@ function DashboardRow({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const isFavorite = useDashboardViewsStore((s) =>
|
||||
s.favorites.includes(dashboard.id),
|
||||
);
|
||||
const toggleFavorite = useDashboardViewsStore((s) => s.toggleFavorite);
|
||||
const markViewed = useDashboardViewsStore((s) => s.markViewed);
|
||||
|
||||
const id = dashboard.id;
|
||||
const name = dashboard.spec?.display?.name ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
@@ -61,7 +53,6 @@ function DashboardRow({
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
logEvent('Dashboard List: Clicked on dashboard', {
|
||||
dashboardId: id,
|
||||
@@ -69,11 +60,6 @@ function DashboardRow({
|
||||
});
|
||||
};
|
||||
|
||||
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
toggleFavorite(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.row} onClick={onClickHandler}>
|
||||
<div className={styles.titleWithAction}>
|
||||
@@ -112,17 +98,6 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.favBtn, { [styles.favBtnOn]: isFavorite })}
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
data-testid={`dashboard-favorite-${index}`}
|
||||
onClick={onToggleFavorite}
|
||||
>
|
||||
<Star size={14} />
|
||||
</button>
|
||||
|
||||
{canAct && (
|
||||
<ActionsPopover
|
||||
link={link}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function CommandHeader({
|
||||
label,
|
||||
count,
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{canCreate && <NewDashboardButton onClick={onCreate} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandHeader;
|
||||
@@ -1,43 +1,14 @@
|
||||
.layout {
|
||||
.container {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
// Deepest layer — the results canvas, so the lighter header zone and the
|
||||
// row cards read with clear contrast (matches the design's list surface).
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.mainScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.headerZone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 20px 24px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.viewContent {
|
||||
width: 100%;
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
|
||||
:global(.ant-table-wrapper) :global(.ant-table-cell) {
|
||||
padding: 0 !important;
|
||||
@@ -45,6 +16,14 @@
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
:global(.ant-table-wrapper)
|
||||
:global(.ant-table-tbody)
|
||||
:global(.ant-table-row)
|
||||
:global(.ant-table-cell)
|
||||
> div {
|
||||
// Row content is the only child of the td; it carries the borders.
|
||||
}
|
||||
|
||||
:global(.ant-table-wrapper)
|
||||
:global(.ant-table-tbody)
|
||||
:global(.ant-table-row:last-child)
|
||||
@@ -76,43 +55,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.commandHeader {
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -125,16 +80,17 @@
|
||||
}
|
||||
|
||||
.integrationsContainer {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.integrationsContent {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
|
||||
// The shared request banner ships a 12px margin; drop it so the banner's
|
||||
// left edge lines up with the heading and filters above/below it.
|
||||
:global(.request-entity-container) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
createDashboardV2,
|
||||
useListDashboardsV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { combineQueries } from '../../filterQuery';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
usePage,
|
||||
useSearch,
|
||||
useSortColumn,
|
||||
useSortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { applyClientView } from '../../views';
|
||||
import type { CreatorOption } from '../FilterZone/FilterChips';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
import StatusBar from '../StatusBar/StatusBar';
|
||||
import ViewsRail from '../ViewsRail/ViewsRail';
|
||||
import CommandHeader from './CommandHeader';
|
||||
import DashboardsResults from './DashboardsResults';
|
||||
import WorkspaceEmptyState from './WorkspaceEmptyState';
|
||||
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../ConfigureMetadataModal/useDynamicColumns';
|
||||
import CreateDashboardDropdown from '../CreateDashboardDropdown/CreateDashboardDropdown';
|
||||
import ImportJSONModal from '../ImportJSONModal/ImportJSONModal';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import EmptyState from '../states/EmptyState/EmptyState';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
import NoResultsState from '../states/NoResultsState/NoResultsState';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
// Favorites / recently-viewed are filtered client-side (no server id filter), so
|
||||
// we pull a single large page and constrain it in-memory.
|
||||
const CLIENT_VIEW_LIMIT = 200;
|
||||
|
||||
function DashboardsList(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const { user } = useAppContext();
|
||||
@@ -48,100 +58,38 @@ function DashboardsList(): JSX.Element {
|
||||
user.role,
|
||||
);
|
||||
|
||||
const {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: filtersEmpty,
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
} = useDashboardFilters();
|
||||
const [searchString, setSearchString] = useSearch();
|
||||
const [sortColumn, setSortColumn] = useSortColumn();
|
||||
const [sortOrder, setSortOrder] = useSortOrder();
|
||||
const [page, setPage] = usePage();
|
||||
|
||||
const {
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
viewQuery,
|
||||
clientView,
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
} = useActiveView({ filters, applyFilters, userEmail: user.email });
|
||||
const [searchInput, setSearchInput] = useState(searchString);
|
||||
|
||||
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
|
||||
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
|
||||
const favorites = useDashboardViewsStore((s) => s.favorites);
|
||||
const recent = useDashboardViewsStore((s) => s.recent);
|
||||
// Keep the local input in sync with external searchString changes
|
||||
// (browser back/forward, deep link). User typing only mutates
|
||||
// searchInput, so this won't fight with in-flight edits.
|
||||
useEffect(() => {
|
||||
setSearchInput(searchString);
|
||||
}, [searchString]);
|
||||
|
||||
// Any filter change resets to the first page so the user isn't stranded on a
|
||||
// now-out-of-range offset.
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string): void => {
|
||||
setSearch(value);
|
||||
void setPage(1);
|
||||
},
|
||||
[setSearch, setPage],
|
||||
);
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
setCreatedBy(emails);
|
||||
void setPage(1);
|
||||
},
|
||||
[setCreatedBy, setPage],
|
||||
);
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
setUpdated(window);
|
||||
void setPage(1);
|
||||
},
|
||||
[setUpdated, setPage],
|
||||
);
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAll();
|
||||
const handleSubmitSearch = useCallback((): void => {
|
||||
const next = searchInput.trim();
|
||||
if (next === searchString) {
|
||||
return;
|
||||
}
|
||||
void setSearchString(next);
|
||||
void setPage(1);
|
||||
}, [clearAll, setPage]);
|
||||
|
||||
// View actions that change the result set reset pagination too.
|
||||
const handleSelectView = useCallback(
|
||||
(id: string): void => {
|
||||
selectView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[selectView, setPage],
|
||||
);
|
||||
const handleResetView = useCallback((): void => {
|
||||
resetView();
|
||||
void setPage(1);
|
||||
}, [resetView, setPage]);
|
||||
const handleRemoveView = useCallback(
|
||||
(id: string): void => {
|
||||
removeView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[removeView, setPage],
|
||||
);
|
||||
const toggleRail = useCallback((): void => {
|
||||
setRailCollapsed(!railCollapsed);
|
||||
}, [setRailCollapsed, railCollapsed]);
|
||||
}, [searchInput, searchString, setSearchString, setPage]);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: combineQueries(viewQuery, query) || undefined,
|
||||
query: searchString.trim() || undefined,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
|
||||
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[viewQuery, query, sortColumn, sortOrder, page, clientView],
|
||||
[searchString, sortColumn, sortOrder, page],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -159,49 +107,52 @@ function DashboardsList(): JSX.Element {
|
||||
const errorHttpStatus = apiError?.getHttpStatusCode();
|
||||
const errorMessage = apiError?.getErrorMessage();
|
||||
|
||||
const rawDashboards = useMemo<DashboardListItem[]>(
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
() => response?.data?.dashboards ?? [],
|
||||
[response],
|
||||
);
|
||||
const total = response?.data?.total ?? 0;
|
||||
|
||||
// Favorites / recently-viewed constrain the fetched rows by a client-side id
|
||||
// set; all other views are already constrained server-side.
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
() =>
|
||||
clientView
|
||||
? applyClientView(rawDashboards, activeViewId, favorites, recent)
|
||||
: rawDashboards,
|
||||
[clientView, rawDashboards, activeViewId, favorites, recent],
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Creator filter options: distinct authors on the loaded page plus the
|
||||
// current user (so "me" is always selectable). Page-scoped until a members
|
||||
// source backs this.
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const emails = new Set<string>();
|
||||
if (user.email) {
|
||||
emails.add(user.email);
|
||||
}
|
||||
rawDashboards.forEach((d) => {
|
||||
if (d.createdBy) {
|
||||
emails.add(d.createdBy);
|
||||
}
|
||||
});
|
||||
return [...emails].sort().map((email) => ({
|
||||
email,
|
||||
label: email === user.email ? `${email} (me)` : email,
|
||||
}));
|
||||
}, [rawDashboards, user.email]);
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isConfigureOpen, setIsConfigureOpen] = useState(false);
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
|
||||
const openCreate = useCallback((): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
setIsCreateOpen(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreateNew = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
setCreating(true);
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
// Backend requires `name` (immutable, server-side identifier);
|
||||
// asking it to generate one keeps the UI's "new dashboard" flow.
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
|
||||
},
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [safeNavigate, showErrorModal, t]);
|
||||
|
||||
const handleImportToggle = useCallback((): void => {
|
||||
logEvent('Dashboard List V2: Import JSON clicked', {});
|
||||
setIsImportOpen((s) => !s);
|
||||
}, []);
|
||||
|
||||
const onSortChange = useCallback(
|
||||
@@ -229,109 +180,102 @@ function DashboardsList(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
const activeLabel =
|
||||
customViews.find((v) => v.id === activeViewId)?.name ??
|
||||
builtinViews.find((v) => v.id === activeViewId)?.label ??
|
||||
'Dashboards';
|
||||
|
||||
// The workspace-empty CTA ("create your first dashboard") belongs only to the
|
||||
// unfiltered All view; every other view's zero result is a no-results state.
|
||||
const showWorkspaceEmpty =
|
||||
!error &&
|
||||
dashboards.length === 0 &&
|
||||
activeViewId === 'all' &&
|
||||
filtersEmpty &&
|
||||
page === 1;
|
||||
|
||||
const isWorkspaceEmpty = showWorkspaceEmpty && !isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<ViewsRail
|
||||
activeViewId={activeViewId}
|
||||
builtinViews={builtinViews}
|
||||
customViews={customViews}
|
||||
isCustomActive={isCustomActive}
|
||||
isModified={isModified}
|
||||
collapsed={railCollapsed}
|
||||
onSelect={handleSelectView}
|
||||
onSave={saveView}
|
||||
onSaveChanges={saveActiveView}
|
||||
onReset={handleResetView}
|
||||
onClearFilters={handleClearAll}
|
||||
onDelete={handleRemoveView}
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.mainScroll}>
|
||||
{isWorkspaceEmpty ? (
|
||||
<WorkspaceEmptyState
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.headerZone}>
|
||||
<CommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
<FilterZone
|
||||
search={filters.search}
|
||||
createdBy={filters.createdBy}
|
||||
updated={filters.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
isEmpty={filtersEmpty}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.viewContent}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Typography.Title className={styles.title}>Dashboards</Typography.Title>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Create and manage dashboards for your workspace.
|
||||
</Typography.Text>
|
||||
{isCloudUser && (
|
||||
<div className={styles.integrationsContainer}>
|
||||
<div className={styles.integrationsContent}>
|
||||
<RequestDashboardBtn />
|
||||
</div>
|
||||
<div className={styles.viewContent}>
|
||||
<DashboardsResults
|
||||
isLoading={isLoading}
|
||||
hasError={!!error}
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
errorHttpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
dashboards={dashboards}
|
||||
activeViewId={activeViewId}
|
||||
searchValue={filters.search}
|
||||
hasFilters={!filtersEmpty}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : !error && dashboards.length === 0 && !searchString && page === 1 ? (
|
||||
<EmptyState
|
||||
createDropdown={
|
||||
canCreateNewDashboard ? (
|
||||
<CreateDashboardDropdown
|
||||
canCreate={!!canCreateNewDashboard}
|
||||
onCreate={handleCreateNew}
|
||||
onImportJSON={handleImportToggle}
|
||||
variant="text"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmitSearch}
|
||||
/>
|
||||
{canCreateNewDashboard && (
|
||||
<CreateDashboardDropdown
|
||||
canCreate={!!canCreateNewDashboard}
|
||||
onCreate={handleCreateNew}
|
||||
onImportJSON={handleImportToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<ErrorState
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
httpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
) : dashboards.length === 0 ? (
|
||||
<NoResultsState searchString={searchInput} />
|
||||
) : (
|
||||
<>
|
||||
<ListHeader
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
sortOrder={sortOrder}
|
||||
onOrderChange={onOrderChange}
|
||||
onConfigureMetadata={(): void => setIsConfigureOpen(true)}
|
||||
/>
|
||||
<DashboardsListContent
|
||||
dashboards={dashboards}
|
||||
page={page}
|
||||
pageSize={clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
canAct={!!action}
|
||||
showUpdatedAt={visibleColumns.updatedAt}
|
||||
showUpdatedBy={visibleColumns.updatedBy}
|
||||
loading={isFetching}
|
||||
loading={creating || isFetching}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
count={dashboards.length}
|
||||
total={total}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ImportJSONModal
|
||||
open={isImportOpen}
|
||||
onClose={(): void => setIsImportOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfigureMetadataModal
|
||||
open={isConfigureOpen}
|
||||
previewDashboard={dashboards[0]}
|
||||
onClose={(): void => setIsConfigureOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewDashboardModal
|
||||
open={isCreateOpen}
|
||||
onClose={(): void => setIsCreateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { noResultsCopy } from '../../views';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
import NoResultsState from '../states/NoResultsState/NoResultsState';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
isCloudUser: boolean;
|
||||
onRetry: () => void;
|
||||
errorHttpStatus?: number;
|
||||
errorMessage?: string;
|
||||
dashboards: DashboardListItem[];
|
||||
activeViewId: string;
|
||||
searchValue: string;
|
||||
hasFilters: boolean;
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
canAct: boolean;
|
||||
showUpdatedAt: boolean;
|
||||
showUpdatedBy: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function DashboardsResults({
|
||||
isLoading,
|
||||
hasError,
|
||||
isCloudUser,
|
||||
onRetry,
|
||||
errorHttpStatus,
|
||||
errorMessage,
|
||||
dashboards,
|
||||
activeViewId,
|
||||
searchValue,
|
||||
hasFilters,
|
||||
sortColumn,
|
||||
onSortChange,
|
||||
sortOrder,
|
||||
onOrderChange,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
canAct,
|
||||
showUpdatedAt,
|
||||
showUpdatedBy,
|
||||
loading,
|
||||
}: Props): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (hasError) {
|
||||
return (
|
||||
<ErrorState
|
||||
isCloudUser={isCloudUser}
|
||||
onRetry={onRetry}
|
||||
httpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (dashboards.length === 0) {
|
||||
const copy = noResultsCopy(activeViewId, searchValue, hasFilters);
|
||||
return <NoResultsState title={copy.title} description={copy.description} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ListHeader
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
sortOrder={sortOrder}
|
||||
onOrderChange={onOrderChange}
|
||||
/>
|
||||
<DashboardsListContent
|
||||
dashboards={dashboards}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={onPageChange}
|
||||
canAct={canAct}
|
||||
showUpdatedAt={showUpdatedAt}
|
||||
showUpdatedBy={showUpdatedBy}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardsResults;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function NewDashboardButton({ onClick }: Props): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onClick}
|
||||
testId="new-dashboard-cta"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewDashboardButton;
|
||||
@@ -1,23 +0,0 @@
|
||||
import EmptyState from '../states/EmptyState/EmptyState';
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
interface Props {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function WorkspaceEmptyState({ canCreate, onCreate }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyWrap}>
|
||||
<EmptyState
|
||||
createDropdown={
|
||||
canCreate ? <NewDashboardButton onClick={onCreate} /> : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceEmptyState;
|
||||
@@ -0,0 +1,3 @@
|
||||
import DashboardsList from './DashboardsList';
|
||||
|
||||
export default DashboardsList;
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { CalendarClock, ChevronDown, User } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
export interface CreatorOption {
|
||||
email: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
any: 'Any time',
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
creatorOptions: CreatorOption[];
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
createdBy,
|
||||
updated,
|
||||
creatorOptions,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
}: Props): JSX.Element {
|
||||
const createdByLabel = useMemo((): string => {
|
||||
if (createdBy.length === 0) {
|
||||
return 'Anyone';
|
||||
}
|
||||
if (createdBy.length === 1) {
|
||||
const match = creatorOptions.find((o) => o.email === createdBy[0]);
|
||||
return match?.label ?? createdBy[0];
|
||||
}
|
||||
return `${createdBy.length} people`;
|
||||
}, [createdBy, creatorOptions]);
|
||||
|
||||
const createdByItems = useMemo<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = creatorOptions.map((option) => ({
|
||||
type: 'checkbox',
|
||||
key: option.email,
|
||||
label: option.label,
|
||||
checked: createdBy.includes(option.email),
|
||||
onCheckedChange: (checked: boolean): void =>
|
||||
onCreatedByChange(
|
||||
checked
|
||||
? [...createdBy, option.email]
|
||||
: createdBy.filter((e) => e !== option.email),
|
||||
),
|
||||
}));
|
||||
if (createdBy.length > 0) {
|
||||
items.push({ type: 'divider', key: 'sep' });
|
||||
items.push({
|
||||
key: 'clear',
|
||||
label: 'Clear selection',
|
||||
onClick: (): void => onCreatedByChange([]),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [creatorOptions, createdBy, onCreatedByChange]);
|
||||
|
||||
const updatedItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: updated,
|
||||
onChange: (value: string): void => onUpdatedChange(value as UpdatedWindow),
|
||||
children: UPDATED_WINDOWS.map((window) => ({
|
||||
type: 'radio',
|
||||
key: window,
|
||||
value: window,
|
||||
label: UPDATED_LABELS[window],
|
||||
})),
|
||||
},
|
||||
],
|
||||
[updated, onUpdatedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<DropdownMenuSimple menu={{ items: createdByItems }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<User size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, {
|
||||
[styles.chipActive]: createdBy.length > 0,
|
||||
})}
|
||||
testId="dashboards-filter-created-by"
|
||||
>
|
||||
Created by: {createdByLabel}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<DropdownMenuSimple menu={{ items: updatedItems }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<CalendarClock size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, {
|
||||
[styles.chipActive]: updated !== 'any',
|
||||
})}
|
||||
testId="dashboards-filter-updated"
|
||||
>
|
||||
Updated: {UPDATED_LABELS[updated]}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterChips;
|
||||
@@ -1,50 +0,0 @@
|
||||
.filterZone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filtersLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
border-color: var(--primary-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import FilterChips, { type CreatorOption } from './FilterChips';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
creatorOptions: CreatorOption[];
|
||||
isEmpty: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
onClearAll: () => void;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
// The filter command zone: name search + structured chips (created-by, updated)
|
||||
// + clear-all. Search is committed on submit/blur (matching the prior bar);
|
||||
// chips apply immediately.
|
||||
function FilterZone({
|
||||
search,
|
||||
createdBy,
|
||||
updated,
|
||||
creatorOptions,
|
||||
isEmpty,
|
||||
onSearchChange,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onClearAll,
|
||||
rightSlot,
|
||||
}: Props): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
|
||||
// Keep the local input in sync with external search changes (applying a view,
|
||||
// clear-all, back/forward). User typing only mutates the local copy.
|
||||
useEffect(() => {
|
||||
setSearchInput(search);
|
||||
}, [search]);
|
||||
|
||||
const handleSubmit = useCallback((): void => {
|
||||
const next = searchInput.trim();
|
||||
if (next !== search) {
|
||||
onSearchChange(next);
|
||||
}
|
||||
}, [searchInput, search, onSearchChange]);
|
||||
|
||||
return (
|
||||
<div className={styles.filterZone}>
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.searchInput}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
placeholder="Search dashboards by name"
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
{rightSlot}
|
||||
</div>
|
||||
<div className={styles.filtersRow}>
|
||||
<span className={styles.filtersLabel}>Filters</span>
|
||||
<FilterChips
|
||||
createdBy={createdBy}
|
||||
updated={updated}
|
||||
creatorOptions={creatorOptions}
|
||||
onCreatedByChange={onCreatedByChange}
|
||||
onUpdatedChange={onUpdatedChange}
|
||||
/>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<X size={12} />}
|
||||
onClick={onClearAll}
|
||||
testId="dashboards-filter-clear"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterZone;
|
||||
@@ -0,0 +1,73 @@
|
||||
.contentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contentHeader {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--warning-background);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.importJsonModalWrapper) {
|
||||
:global(.ant-modal-content) {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.margin) {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
:global(.view-lines) {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
:global(.ant-modal-footer) {
|
||||
margin-top: 0;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { red } from '@ant-design/colors';
|
||||
import MEditor, { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Flex, Modal, Upload, UploadProps } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
Github,
|
||||
MonitorDot,
|
||||
MoveRight,
|
||||
Sparkles,
|
||||
} from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import sampleDashboard from './sampleDashboard.json';
|
||||
|
||||
import styles from './ImportJSONModal.module.scss';
|
||||
import { normalizeToPostable } from './ImportJSONModalUtils';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ImportJSONModal({ open, onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const [isUploadError, setIsUploadError] = useState(false);
|
||||
const [isCreateError, setIsCreateError] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editorValue, setEditorValue] = useState('');
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleUpload: UploadProps['onChange'] = (info) => {
|
||||
const lastFile = info.fileList[info.fileList.length - 1];
|
||||
if (!lastFile?.originFileObj) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event): void => {
|
||||
try {
|
||||
const target = event.target?.result;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(target.toString());
|
||||
setEditorValue(JSON.stringify(parsed, null, 2));
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
setIsUploadError(true);
|
||||
}
|
||||
};
|
||||
reader.readAsText(lastFile.originFileObj);
|
||||
};
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
const response = await createDashboardV2(payload);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
logEvent('Dashboard List V2: New dashboard imported successfully', {
|
||||
dashboardId: response.data?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsCreateError(true);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('error_loading_json'),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setIsUploadError(false);
|
||||
setIsCreateError(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const setEditorTheme = (monaco: Monaco): void => {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: { 'editor.background': Color.BG_INK_300 },
|
||||
});
|
||||
};
|
||||
|
||||
const renderError = (msg: string): JSX.Element => (
|
||||
<div className={styles.jsonError}>
|
||||
<CircleAlert size="md" color={red[7]} />
|
||||
<Typography className={styles.errorText}>{msg}</Typography>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
wrapClassName="importJsonModalWrapper"
|
||||
open={open}
|
||||
centered
|
||||
closable
|
||||
keyboard
|
||||
maskClosable
|
||||
onCancel={handleClose}
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
{isCreateError && renderError(t('error_loading_json'))}
|
||||
{isUploadError && renderError(t('error_upload_json'))}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Flex gap="small">
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
multiple={false}
|
||||
onChange={handleUpload}
|
||||
beforeUpload={(): boolean => false}
|
||||
action="none"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
icon={<MonitorDot size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
}}
|
||||
>
|
||||
{t('upload_json_file')}
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
icon={<Sparkles size={14} />}
|
||||
onClick={(): void => {
|
||||
setEditorValue(JSON.stringify(sampleDashboard, null, 2));
|
||||
setIsUploadError(false);
|
||||
logEvent('Dashboard List V2: Load sample clicked', {});
|
||||
}}
|
||||
>
|
||||
Load sample
|
||||
</Button>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
icon={<Github size={14} />}
|
||||
>
|
||||
{t('view_template')}
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</a>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
loading={isCreating}
|
||||
className="periscope-btn primary"
|
||||
type="primary"
|
||||
>
|
||||
{t('import_and_next')} <MoveRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.contentContainer}>
|
||||
<div className={styles.contentHeader}>
|
||||
<Typography.Text>{t('import_json')}</Typography.Text>
|
||||
</div>
|
||||
<MEditor
|
||||
language="json"
|
||||
height="40vh"
|
||||
onChange={(newValue): void => setEditorValue(newValue || '')}
|
||||
value={editorValue}
|
||||
options={{
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: 'Space Mono',
|
||||
}}
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
onMount={(_, monaco): void => {
|
||||
document.fonts.ready.then(() => {
|
||||
monaco.editor.remeasureFonts();
|
||||
});
|
||||
}}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportJSONModal;
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "NV dashboard with sections",
|
||||
"description": ""
|
||||
},
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/Datasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "container.cpu.time",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"251df4d5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "recommendations",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {"fillOnlyBelow": true}
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "app_recommendations_counter",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Bravo"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/b424e23b"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Alpha"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/251df4d5"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px 6px 0px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -22,36 +23,10 @@
|
||||
|
||||
.rightActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sortPrefix {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Inline metadata-visibility toggles (replaces the configure modal).
|
||||
.metaPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Shared trigger button for the sort + configure-group icons in the right
|
||||
// actions cluster. Provides a square hover/active background so users know
|
||||
// which icon they're targeting.
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
// eslint-disable-next-line signoz/no-antd-components -- Popover/Tooltip not yet migrated for this menu
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowDown, ArrowUp, Check, HdmiPort } from '@signozhq/icons';
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Check,
|
||||
Ellipsis,
|
||||
HdmiPort,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
type DashboardDynamicColumns,
|
||||
useDashboardsListVisibleColumnsStore,
|
||||
} from '../../store/useVisibleColumnsStore';
|
||||
|
||||
import styles from './ListHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -22,178 +19,131 @@ interface Props {
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
onConfigureMetadata: () => void;
|
||||
}
|
||||
|
||||
const SORT_LABELS: Record<DashboardtypesListSortDTO, string> = {
|
||||
[DashboardtypesListSortDTO.updated_at]: 'Last updated',
|
||||
[DashboardtypesListSortDTO.created_at]: 'Last created',
|
||||
[DashboardtypesListSortDTO.name]: 'Name',
|
||||
};
|
||||
|
||||
// Created-at / created-by are always shown; only the "updated" columns toggle.
|
||||
const METADATA_COLUMNS: {
|
||||
key: keyof DashboardDynamicColumns;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: 'updatedAt', label: 'Updated at' },
|
||||
{ key: 'updatedBy', label: 'Updated by' },
|
||||
];
|
||||
|
||||
function ListHeader({
|
||||
sortColumn,
|
||||
onSortChange,
|
||||
sortOrder,
|
||||
onOrderChange,
|
||||
onConfigureMetadata,
|
||||
}: Props): JSX.Element {
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
const setVisibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.setVisibleColumns,
|
||||
);
|
||||
|
||||
const metadataContent = (
|
||||
<div className={styles.metaPanel}>
|
||||
<Typography.Text className={styles.sortHeading}>Metadata</Typography.Text>
|
||||
{METADATA_COLUMNS.map((col) => (
|
||||
<div key={col.key} className={styles.metaRow}>
|
||||
<Typography.Text className={styles.metaLabel}>{col.label}</Typography.Text>
|
||||
<Switch
|
||||
value={visibleColumns[col.key]}
|
||||
testId={`metadata-toggle-${col.key}`}
|
||||
onChange={(checked): void =>
|
||||
setVisibleColumns({ ...visibleColumns, [col.key]: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Typography.Text className={styles.label}>Results</Typography.Text>
|
||||
<Typography.Text className={styles.label}>All Dashboards</Typography.Text>
|
||||
<section className={styles.rightActions}>
|
||||
<Tooltip title="Sort">
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className={styles.sortContent}>
|
||||
<Typography.Text className={styles.sortHeading}>
|
||||
Sort By
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
|
||||
data-testid="sort-by-name"
|
||||
>
|
||||
Name
|
||||
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.created_at)
|
||||
}
|
||||
data-testid="sort-by-last-created"
|
||||
>
|
||||
Last created
|
||||
{sortColumn === DashboardtypesListSortDTO.created_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.updated_at)
|
||||
}
|
||||
data-testid="sort-by-last-updated"
|
||||
>
|
||||
Last updated
|
||||
{sortColumn === DashboardtypesListSortDTO.updated_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</Button>
|
||||
<div className={styles.sortDivider} />
|
||||
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
|
||||
data-testid="sort-order-asc"
|
||||
>
|
||||
Ascending
|
||||
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
|
||||
data-testid="sort-order-desc"
|
||||
>
|
||||
Descending
|
||||
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="sortDashboardsPopover"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconTrigger}
|
||||
data-testid="sort-by"
|
||||
aria-label="Sort"
|
||||
>
|
||||
<ArrowDownWideNarrow size={14} />
|
||||
</button>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className={styles.sortContent}>
|
||||
<Typography.Text className={styles.sortHeading}>Sort By</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
|
||||
testId="sort-by-name"
|
||||
suffix={
|
||||
sortColumn === DashboardtypesListSortDTO.name ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
<div className={styles.configureContent}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.configureItem}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onConfigureMetadata();
|
||||
}}
|
||||
data-testid="configure-metadata-trigger"
|
||||
>
|
||||
Name
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.created_at)}
|
||||
testId="sort-by-last-created"
|
||||
suffix={
|
||||
sortColumn === DashboardtypesListSortDTO.created_at ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Last created
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.updated_at)}
|
||||
testId="sort-by-last-updated"
|
||||
suffix={
|
||||
sortColumn === DashboardtypesListSortDTO.updated_at ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Last updated
|
||||
</Button>
|
||||
<div className={styles.sortDivider} />
|
||||
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
|
||||
testId="sort-order-asc"
|
||||
suffix={
|
||||
sortOrder === DashboardtypesListOrderDTO.asc ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Ascending
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
|
||||
testId="sort-order-desc"
|
||||
suffix={
|
||||
sortOrder === DashboardtypesListOrderDTO.desc ? (
|
||||
<Check size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Descending
|
||||
</Button>
|
||||
<span className={styles.configureIcon}>
|
||||
<HdmiPort size={14} />
|
||||
</span>
|
||||
<span>Configure metadata</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="sortDashboardsPopover"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
testId="sort-by"
|
||||
aria-label="Sort"
|
||||
suffix={
|
||||
sortOrder === DashboardtypesListOrderDTO.asc ? (
|
||||
<ArrowUp size={12} />
|
||||
) : (
|
||||
<ArrowDown size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={styles.sortPrefix}>Sort:</span>{' '}
|
||||
{SORT_LABELS[sortColumn]}{' '}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={metadataContent}
|
||||
rootClassName="configureGroupPopover"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<Tooltip title="Metadata">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Metadata"
|
||||
testId="configure-metadata-trigger"
|
||||
>
|
||||
<HdmiPort size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconTrigger}
|
||||
aria-label="More options"
|
||||
>
|
||||
<Ellipsis size={14} />
|
||||
</button>
|
||||
</Popover>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- no @signozhq/ui multiline TextArea yet
|
||||
import { Input as AntInput } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { AxiosError } from 'axios';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { toPostableTags } from '../../utils';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
const DEFAULT_NAME = 'Sample Dashboard';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [name, setName] = useState(DEFAULT_NAME);
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = name.trim().length > 0 && !submitting;
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
const postableTags = toPostableTags(tags);
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: postableTags.length ? postableTags : null,
|
||||
spec: {
|
||||
display: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Title <span className={styles.required}>*</span>
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
{/* eslint-disable-next-line signoz/no-antd-components -- no @signozhq TextArea yet */}
|
||||
<AntInput.TextArea
|
||||
value={description}
|
||||
rows={3}
|
||||
placeholder="What is this dashboard for?"
|
||||
data-testid="create-dashboard-description"
|
||||
onChange={(e): void => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>Tags</Typography.Text>
|
||||
<Input
|
||||
value={tags}
|
||||
placeholder="team:jarvis, prod"
|
||||
testId="create-dashboard-tags"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setTags(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Comma-separated. Use key:value (e.g. team:jarvis) or a single label.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="create-dashboard-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={!canSubmit}
|
||||
testId="create-dashboard-submit"
|
||||
onClick={(): void => {
|
||||
void handleCreate();
|
||||
}}
|
||||
>
|
||||
Create dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlankDashboardPanel;
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { red } from '@ant-design/colors';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- Upload has no @signozhq/ui equivalent yet
|
||||
import { Upload, UploadProps } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CircleAlert, MonitorDot, MoveRight } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
import JsonEditor from './JsonEditor';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
function ImportJsonPanel(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [editorValue, setEditorValue] = useState('');
|
||||
const [isUploadError, setIsUploadError] = useState(false);
|
||||
const [isCreateError, setIsCreateError] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleUpload: UploadProps['onChange'] = (info) => {
|
||||
const lastFile = info.fileList[info.fileList.length - 1];
|
||||
if (!lastFile?.originFileObj) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event): void => {
|
||||
try {
|
||||
const target = event.target?.result;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(target.toString());
|
||||
setEditorValue(JSON.stringify(parsed, null, 2));
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
setIsUploadError(true);
|
||||
}
|
||||
};
|
||||
reader.readAsText(lastFile.originFileObj);
|
||||
};
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
const response = await createDashboardV2(payload);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsCreateError(true);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('error_loading_json'),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<Typography.Text className={styles.importHeader}>
|
||||
{t('import_json')}
|
||||
</Typography.Text>
|
||||
|
||||
<JsonEditor value={editorValue} onChange={setEditorValue} height="280px" />
|
||||
|
||||
{(isCreateError || isUploadError) && (
|
||||
<div className={styles.jsonError}>
|
||||
<CircleAlert size="md" color={red[7]} />
|
||||
<Typography className={styles.errorText}>
|
||||
{isUploadError ? t('error_upload_json') : t('error_loading_json')}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.importFooter}>
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
multiple={false}
|
||||
onChange={handleUpload}
|
||||
beforeUpload={(): boolean => false}
|
||||
action="none"
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<MonitorDot size={14} />}
|
||||
testId="upload-json-file"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
}}
|
||||
>
|
||||
{t('upload_json_file')}
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isCreating}
|
||||
suffix={<MoveRight size={14} />}
|
||||
testId="import-json-submit"
|
||||
onClick={handleImport}
|
||||
>
|
||||
{t('import_and_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportJsonPanel;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import MEditor, { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Maximize2 } from '@signozhq/icons';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const defineTheme = (monaco: Monaco): void => {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: { 'editor.background': Color.BG_INK_300 },
|
||||
});
|
||||
};
|
||||
|
||||
// JSON editor with a one-click "expand" into an extra-wide modal for easier
|
||||
// editing/review. The expanded editor shares the same value, so edits persist.
|
||||
function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
height = '38vh',
|
||||
}: Props): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const renderEditor = (editorHeight: string): JSX.Element => (
|
||||
<MEditor
|
||||
language="json"
|
||||
height={editorHeight}
|
||||
value={value}
|
||||
onChange={(next): void => onChange?.(next || '')}
|
||||
options={{
|
||||
readOnly,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: 'Space Mono',
|
||||
}}
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
beforeMount={defineTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.editorWrap}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.expandBtn}
|
||||
aria-label="Expand editor"
|
||||
testId="json-editor-expand"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
<div className={styles.editor}>{renderEditor(height)}</div>
|
||||
|
||||
<DialogWrapper
|
||||
title={readOnly ? 'Preview JSON' : 'Edit JSON'}
|
||||
open={expanded}
|
||||
width="extra-wide"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.editorExpanded}>{renderEditor('70vh')}</div>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -1,195 +0,0 @@
|
||||
// Fixed height so the modal doesn't resize when switching tabs.
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 12px;
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cardName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 12px;
|
||||
|
||||
:global(.request-entity-container) {
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.importHeader {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expandBtn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.editor {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorExpanded {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.templatesLayout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.templatesList {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.templateItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.templateItem:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.templateItemActive {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--primary-background);
|
||||
}
|
||||
|
||||
.templateName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.templateCat {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.templatesPreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.previewHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--danger-background);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.importFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import BlankDashboardPanel from './BlankDashboardPanel';
|
||||
import ImportJsonPanel from './ImportJsonPanel';
|
||||
import TemplatesPanel from './TemplatesPanel';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
const [tab, setTab] = useState('blank');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab('blank');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New dashboard"
|
||||
open={open}
|
||||
width="wide"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(key): void => setTab(key)}
|
||||
items={[
|
||||
{
|
||||
key: 'blank',
|
||||
label: 'Blank',
|
||||
children: <BlankDashboardPanel onClose={onClose} />,
|
||||
},
|
||||
{
|
||||
key: 'template',
|
||||
label: 'From a template',
|
||||
children: <TemplatesPanel />,
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: 'Import JSON',
|
||||
children: <ImportJsonPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewDashboardModal;
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ExternalLink, LoaderCircle } from '@signozhq/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
import JsonEditor from './JsonEditor';
|
||||
import { useDashboardTemplates } from './templatesData';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
// Browse the template gallery (mock data until the API lands): pick one on the
|
||||
// left to preview its JSON on the right, then use it or open the docs.
|
||||
function TemplatesPanel(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { data, isLoading } = useDashboardTemplates(true);
|
||||
const templates = data ?? [];
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selected = templates.find((t) => t.id === selectedId) ?? templates[0];
|
||||
|
||||
const handleUse = async (): Promise<void> => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreating(true);
|
||||
logEvent('Dashboard List: Use template clicked', { template: selected.id });
|
||||
const parsed = JSON.parse(selected.json) as Record<string, unknown>;
|
||||
const created = await createDashboardV2(normalizeToPostable(parsed));
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error(
|
||||
(e as AxiosError).toString() || 'Failed to create from template',
|
||||
);
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.loading}>
|
||||
<LoaderCircle size={18} className={styles.spinner} />
|
||||
<span>Loading templates…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.templatesLayout}>
|
||||
<div className={styles.templatesList}>
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={cx(styles.templateItem, {
|
||||
[styles.templateItemActive]: selected?.id === template.id,
|
||||
})}
|
||||
data-testid={`template-${template.id}`}
|
||||
onClick={(): void => setSelectedId(template.id)}
|
||||
>
|
||||
<span className={styles.templateName}>{template.name}</span>
|
||||
<span className={styles.templateCat}>{template.category}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className={styles.templatesPreview}>
|
||||
<div className={styles.previewHead}>
|
||||
<div>
|
||||
<Typography.Text className={styles.cardName}>
|
||||
{selected.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.cardDesc}>
|
||||
{selected.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
suffix={<ExternalLink size={13} />}
|
||||
onClick={(): void => openInNewTab(selected.href)}
|
||||
testId="template-docs"
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<JsonEditor value={selected.json} readOnly height="240px" />
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={creating}
|
||||
testId="use-template"
|
||||
onClick={(): void => {
|
||||
void handleUse();
|
||||
}}
|
||||
>
|
||||
Use template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.requestRow}>
|
||||
<RequestDashboardBtn />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplatesPanel;
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useQuery, type UseQueryResult } from 'react-query';
|
||||
|
||||
export interface DashboardTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
href: string;
|
||||
// Importable dashboard definition previewed in the gallery (mock for now).
|
||||
json: string;
|
||||
}
|
||||
|
||||
// A representative dashboard definition for a template — mock until the API
|
||||
// returns real ones.
|
||||
const buildTemplateJson = (
|
||||
name: string,
|
||||
description: string,
|
||||
category: string,
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: [{ key: 'category', value: category.toLowerCase() }],
|
||||
spec: {
|
||||
display: { name, description },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Mock catalogue until the templates API lands. Mirrors the public gallery at
|
||||
// https://signoz.io/docs/dashboards/dashboard-templates/overview/
|
||||
const BASE_TEMPLATES: Omit<DashboardTemplate, 'json'>[] = [
|
||||
{
|
||||
id: 'apm',
|
||||
name: 'APM Metrics',
|
||||
description: 'Latency, error rate, and throughput across your services.',
|
||||
category: 'APM',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/apm/',
|
||||
},
|
||||
{
|
||||
id: 'hostmetrics',
|
||||
name: 'Host Metrics',
|
||||
description: 'CPU, memory, disk, and network for your hosts.',
|
||||
category: 'Infra',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/hostmetrics/',
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
name: 'Kubernetes Pod Metrics',
|
||||
description: 'Pod, node, and container health for your clusters.',
|
||||
category: 'Infra',
|
||||
href:
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/kubernetes-pod-metrics-detailed/',
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Connections, throughput, and query performance.',
|
||||
category: 'Databases',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/postgresql/',
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'Memory, commands, and hit-rate for Redis instances.',
|
||||
category: 'Databases',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/redis/',
|
||||
},
|
||||
{
|
||||
id: 'nginx',
|
||||
name: 'NGINX',
|
||||
description: 'Request rate, connections, and error responses.',
|
||||
category: 'Web servers',
|
||||
href: 'https://signoz.io/docs/dashboards/dashboard-templates/nginx/',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_TEMPLATES: DashboardTemplate[] = BASE_TEMPLATES.map((t) => ({
|
||||
...t,
|
||||
json: buildTemplateJson(t.name, t.description, t.category),
|
||||
}));
|
||||
|
||||
// TODO(@AshwinBhatkal): replace with the real templates API when available.
|
||||
// The small delay simulates the network round-trip so the loading state is
|
||||
// exercised (a real API call won't resolve instantly).
|
||||
const fetchDashboardTemplates = (): Promise<DashboardTemplate[]> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_TEMPLATES), 600);
|
||||
});
|
||||
|
||||
export function useDashboardTemplates(
|
||||
enabled: boolean,
|
||||
): UseQueryResult<DashboardTemplate[]> {
|
||||
return useQuery({
|
||||
queryKey: ['dashboard-templates'],
|
||||
queryFn: fetchDashboardTemplates,
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -9,18 +9,12 @@ interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
|
||||
}: Props): JSX.Element {
|
||||
function SearchBar({ value, onChange, onSubmit }: Props): JSX.Element {
|
||||
return (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
placeholder="Search with DSL (e.g. name CONTAINS 'foo')"
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
suffix={
|
||||
<button
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function StatusBar({
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
count,
|
||||
total,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={
|
||||
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
|
||||
}
|
||||
onClick={onToggleCollapse}
|
||||
testId="dashboards-rail-toggle"
|
||||
>
|
||||
{collapsed ? 'Expand' : 'Collapse'}
|
||||
</Button>
|
||||
<Typography.Text className={styles.count}>
|
||||
{count} of {total} dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBar;
|
||||
@@ -1,110 +0,0 @@
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { PopoverSimple } from '@signozhq/ui/popover';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { VIEW_ICON_OPTIONS } from '../../views';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string, icon: string) => void;
|
||||
trigger: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = VIEW_ICON_OPTIONS[0].name;
|
||||
|
||||
function SaveViewPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
trigger,
|
||||
}: Props): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [icon, setIcon] = useState(DEFAULT_ICON);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setIcon(DEFAULT_ICON);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const canSave = name.trim().length > 0;
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSave(name, icon);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverSimple
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
align="start"
|
||||
trigger={trigger}
|
||||
>
|
||||
<div className={styles.savePopover}>
|
||||
<div className={styles.saveTitle}>Save as view</div>
|
||||
<span className={styles.saveLabel}>Name</span>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="e.g. Prod alerts"
|
||||
testId="save-view-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className={styles.saveLabel}>Icon</span>
|
||||
<div className={styles.iconGrid}>
|
||||
{VIEW_ICON_OPTIONS.map(({ name: iconName, Icon }) => (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
aria-label={iconName}
|
||||
className={cx(styles.iconCell, {
|
||||
[styles.iconCellOn]: icon === iconName,
|
||||
})}
|
||||
onClick={(): void => setIcon(iconName)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.saveActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!canSave}
|
||||
testId="save-view-confirm"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewPopover;
|
||||
@@ -1,256 +0,0 @@
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
overflow: hidden;
|
||||
transition: width 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
width: 0;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 12px 10px 16px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
|
||||
.searchEmpty {
|
||||
padding: 12px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 8px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupLabelSpaced {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
letter-spacing: 0;
|
||||
color: var(--l3-foreground);
|
||||
background: var(--l2-background);
|
||||
border-radius: 10px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 3px 0;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.rowActive {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.item {
|
||||
// Neutralise the signoz Button defaults so it reads as a full-width,
|
||||
// left-aligned list row; the row coordinates hover/active colours below.
|
||||
--button-display: flex;
|
||||
--button-justify-content: flex-start;
|
||||
--button-height: auto;
|
||||
--button-padding: 9px 10px;
|
||||
--button-gap: 10px;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.row:hover .item,
|
||||
.rowActive .item {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
display: inline-flex;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowActive .itemIcon {
|
||||
color: var(--primary-background);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
// Square icon button that surfaces on row hover and turns red on its own
|
||||
// hover; colours flow through the signoz Button tokens.
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 4px;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--danger-background);
|
||||
--button-variant-ghost-hover-color: var(--danger-color, #fff);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.row:hover .itemAction {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 4px 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--l2-border);
|
||||
border-radius: 8px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dirtyPanel {
|
||||
flex: none;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.dirtyPanelDefault {
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.dirtyTitle {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dirtyActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.savePopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.saveTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.saveLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.iconCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.iconCell:hover {
|
||||
color: var(--l1-foreground);
|
||||
border-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.iconCellOn {
|
||||
border-color: var(--primary-background);
|
||||
color: var(--primary-background);
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { type ChangeEvent, useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView, iconByName } from '../../views';
|
||||
import SaveViewPopover from './SaveViewPopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
activeViewId: string;
|
||||
builtinViews: BuiltinView[];
|
||||
customViews: SavedView[];
|
||||
isCustomActive: boolean;
|
||||
isModified: boolean;
|
||||
collapsed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSave: (name: string, icon: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onClearFilters: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ViewRow {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: BuiltinView['icon'];
|
||||
deletable?: boolean;
|
||||
}
|
||||
|
||||
// Purely presentational — active view, dirty state, and handlers come from
|
||||
// `useActiveView`.
|
||||
function ViewsRail({
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
collapsed = false,
|
||||
onSelect,
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onClearFilters,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
!q || label.toLowerCase().includes(q);
|
||||
|
||||
const personal = builtinViews.filter(
|
||||
(v) => v.section === 'personal' && matchesQuery(v.label),
|
||||
);
|
||||
const system = builtinViews.filter(
|
||||
(v) => v.section === 'system' && matchesQuery(v.label),
|
||||
);
|
||||
const custom = customViews.filter((v) => matchesQuery(v.name));
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
const { destroy } = modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the
|
||||
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
|
||||
{' '}
|
||||
{label}{' '}
|
||||
</span>
|
||||
view?
|
||||
</Typography.Title>
|
||||
),
|
||||
content: 'This removes the saved view. Your dashboards are not affected.',
|
||||
icon: (
|
||||
<CircleAlert
|
||||
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
|
||||
size="3xl"
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: {
|
||||
danger: true,
|
||||
onClick: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(id);
|
||||
destroy();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
},
|
||||
[modal, onDelete],
|
||||
);
|
||||
|
||||
const renderItem = (row: ViewRow): JSX.Element => {
|
||||
const Icon = row.icon;
|
||||
const active = row.id === activeViewId;
|
||||
return (
|
||||
<div key={row.id} className={cx(styles.row, { [styles.rowActive]: active })}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.item}
|
||||
onClick={(): void => onSelect(row.id)}
|
||||
testId={`dashboards-view-${row.id}`}
|
||||
>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className={styles.itemLabel}>{row.label}</span>
|
||||
{active && isModified && (
|
||||
<span className={styles.dirtyDot} title="Unsaved changes" />
|
||||
)}
|
||||
</Button>
|
||||
{row.deletable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.itemAction}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(): void => confirmDelete(row.id, row.label)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<SaveViewPopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSave={onSave}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
title="Save current filters as a view"
|
||||
testId="dashboards-view-save-trigger"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Search views"
|
||||
prefix={<Search size={12} />}
|
||||
testId="dashboards-view-search"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setQuery(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Personal</div>
|
||||
{personal.map((v) => renderItem(v))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{system.length > 0 && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
System
|
||||
</div>
|
||||
{system.map((v) => renderItem(v))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!q || custom.length > 0) && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
My views
|
||||
<span className={styles.groupCount}>{customViews.length}</span>
|
||||
</div>
|
||||
{customViews.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No saved views yet. Filter the list, then save it as a view.
|
||||
</div>
|
||||
) : (
|
||||
custom.map((v) =>
|
||||
renderItem({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
icon: iconByName(v.icon),
|
||||
deletable: true,
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{noMatches && (
|
||||
<div className={styles.searchEmpty}>
|
||||
No views match “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCustomActive && isModified && (
|
||||
<div className={styles.dirtyPanel}>
|
||||
<div className={styles.dirtyTitle}>Unsaved changes</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onSaveChanges}
|
||||
testId="dashboards-view-save-changes"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setSaveOpen(true)}
|
||||
>
|
||||
Save as…
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCustomActive && isModified && (
|
||||
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
|
||||
<div className={styles.dirtyTitle}>Filters active</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => setSaveOpen(true)}
|
||||
testId="dashboards-view-save-as-new"
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{contextHolder}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRail;
|
||||
@@ -1,18 +1,11 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
height: 125px;
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
|
||||
// antd sizes the inner input element; stretch it to fill the row.
|
||||
:global(.ant-skeleton-input) {
|
||||
width: 100% !important;
|
||||
height: 76px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ import { Skeleton } from 'antd';
|
||||
|
||||
import styles from './LoadingState.module.scss';
|
||||
|
||||
const ROWS = [0, 1, 2, 3, 4];
|
||||
|
||||
function LoadingState(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{ROWS.map((row) => (
|
||||
<Skeleton.Input key={row} active block className={styles.skeleton} />
|
||||
))}
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
<Skeleton.Input active size="large" className={styles.skeleton} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,3 @@
|
||||
padding: 105px 190px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -5,20 +5,16 @@ import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
import styles from './NoResultsState.module.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
searchString: string;
|
||||
}
|
||||
|
||||
function NoResultsState({ title, description }: Props): JSX.Element {
|
||||
function NoResultsState({ searchString }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<img src={emptyStateUrl} alt="" height={32} width={32} />
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<img src={emptyStateUrl} alt="img" height={32} width={32} />
|
||||
<Typography.Text>
|
||||
No dashboards found for {searchString}. Create a new dashboard?
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Pure, side-effect-free helpers that translate the UI filter state into the
|
||||
// backend list-filter DSL string (`GET /api/v2/dashboards?query=…`). Kept
|
||||
// testable and free of React so the same builder backs live filtering, saved
|
||||
// views, and built-in views.
|
||||
//
|
||||
// DSL reference (subset used here):
|
||||
// name CONTAINS 'text'
|
||||
// created_by = 'a@b.com' | created_by IN ['a@b.com', 'c@d.com']
|
||||
// updated_at >= '2026-06-01T00:00:00.000Z' (RFC3339)
|
||||
// locked = true
|
||||
// clauses joined with AND
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { DashboardFilterState, UpdatedWindow } from './types';
|
||||
|
||||
export const DEFAULT_FILTER_STATE: DashboardFilterState = {
|
||||
search: '',
|
||||
createdBy: [],
|
||||
updated: 'any',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOW_DAYS: Record<Exclude<UpdatedWindow, 'any'>, number> = {
|
||||
today: 1,
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
};
|
||||
|
||||
// Single-quoted DSL string literal with embedded quotes escaped.
|
||||
const literal = (value: string): string => `'${value.replace(/'/g, "\\'")}'`;
|
||||
|
||||
const updatedClause = (window: UpdatedWindow): string | null => {
|
||||
if (window === 'any') {
|
||||
return null;
|
||||
}
|
||||
const cutoff = dayjs()
|
||||
.subtract(UPDATED_WINDOW_DAYS[window], 'day')
|
||||
.toISOString();
|
||||
return `updated_at >= ${literal(cutoff)}`;
|
||||
};
|
||||
|
||||
export const filterStateToQuery = (state: DashboardFilterState): string => {
|
||||
const clauses: string[] = [];
|
||||
|
||||
const search = state.search.trim();
|
||||
if (search) {
|
||||
clauses.push(`name CONTAINS ${literal(search)}`);
|
||||
}
|
||||
|
||||
if (state.createdBy.length === 1) {
|
||||
clauses.push(`created_by = ${literal(state.createdBy[0])}`);
|
||||
} else if (state.createdBy.length > 1) {
|
||||
clauses.push(`created_by IN [${state.createdBy.map(literal).join(', ')}]`);
|
||||
}
|
||||
|
||||
const updated = updatedClause(state.updated);
|
||||
if (updated) {
|
||||
clauses.push(updated);
|
||||
}
|
||||
|
||||
return clauses.join(' AND ');
|
||||
};
|
||||
|
||||
// Combine independent query fragments (e.g. a built-in view's `locked = true`
|
||||
// plus the live filter state) into a single AND-composed query.
|
||||
export const combineQueries = (
|
||||
...parts: (string | null | undefined)[]
|
||||
): string =>
|
||||
parts
|
||||
.map((p) => p?.trim())
|
||||
.filter((p): p is string => !!p)
|
||||
.join(' AND ');
|
||||
|
||||
export const isFilterStateEmpty = (state: DashboardFilterState): boolean =>
|
||||
!state.search.trim() &&
|
||||
state.createdBy.length === 0 &&
|
||||
state.updated === 'any';
|
||||
|
||||
export const areFilterStatesEqual = (
|
||||
a: DashboardFilterState,
|
||||
b: DashboardFilterState,
|
||||
): boolean =>
|
||||
a.search.trim() === b.search.trim() &&
|
||||
a.updated === b.updated &&
|
||||
a.createdBy.length === b.createdBy.length &&
|
||||
[...a.createdBy].sort().join(',') === [...b.createdBy].sort().join(',');
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { parseAsString, useQueryState, type Options } from 'nuqs';
|
||||
|
||||
import { DEFAULT_FILTER_STATE, areFilterStatesEqual } from '../filterQuery';
|
||||
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
|
||||
import type { DashboardFilterState, SavedView } from '../types';
|
||||
import {
|
||||
BUILTIN_VIEWS,
|
||||
builtinViewQuery,
|
||||
builtinViewSnapshot,
|
||||
type BuiltinView,
|
||||
isClientView,
|
||||
} from '../views';
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
interface UseActiveViewArgs {
|
||||
filters: DashboardFilterState;
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export interface UseActiveViewResult {
|
||||
activeViewId: string;
|
||||
builtinViews: BuiltinView[];
|
||||
customViews: SavedView[];
|
||||
isCustomActive: boolean;
|
||||
// Current filters diverge from the active view's canonical snapshot.
|
||||
isModified: boolean;
|
||||
// Extra server-query fragment the active view contributes, and whether it
|
||||
// constrains the list client-side (favorites/recent).
|
||||
viewQuery: string;
|
||||
clientView: boolean;
|
||||
selectView: (id: string) => void;
|
||||
saveView: (name: string, icon: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => void;
|
||||
}
|
||||
|
||||
// Orchestrates the active view: which view is selected (URL `view` param),
|
||||
// merging built-in + persisted custom views, applying a view's snapshot on
|
||||
// select, dirty detection, and save/reset/delete.
|
||||
export function useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
userEmail,
|
||||
}: UseActiveViewArgs): UseActiveViewResult {
|
||||
const [activeViewId, setActiveViewId] = useQueryState(
|
||||
'view',
|
||||
parseAsString.withDefault('all').withOptions(opts),
|
||||
);
|
||||
|
||||
const customViews = useDashboardViewsStore((s) => s.customViews);
|
||||
const addView = useDashboardViewsStore((s) => s.addView);
|
||||
const updateView = useDashboardViewsStore((s) => s.updateView);
|
||||
const deleteView = useDashboardViewsStore((s) => s.deleteView);
|
||||
|
||||
const activeCustom = useMemo(
|
||||
() => customViews.find((v) => v.id === activeViewId),
|
||||
[customViews, activeViewId],
|
||||
);
|
||||
|
||||
// The filter state the active view "is" — used to detect divergence.
|
||||
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
|
||||
() =>
|
||||
activeCustom
|
||||
? activeCustom.filters
|
||||
: builtinViewSnapshot(activeViewId, userEmail),
|
||||
[activeCustom, activeViewId, userEmail],
|
||||
);
|
||||
|
||||
const isModified = canonicalSnapshot
|
||||
? !areFilterStatesEqual(filters, canonicalSnapshot)
|
||||
: false;
|
||||
|
||||
const selectView = useCallback(
|
||||
(id: string): void => {
|
||||
void setActiveViewId(id);
|
||||
const custom = customViews.find((v) => v.id === id);
|
||||
applyFilters(
|
||||
custom?.filters ??
|
||||
builtinViewSnapshot(id, userEmail) ??
|
||||
DEFAULT_FILTER_STATE,
|
||||
);
|
||||
},
|
||||
[setActiveViewId, customViews, applyFilters, userEmail],
|
||||
);
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string, icon: string): void => {
|
||||
const id = `cv_${Date.now()}`;
|
||||
addView({
|
||||
id,
|
||||
name: name.trim(),
|
||||
icon,
|
||||
filters: { ...filters },
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
void setActiveViewId(id);
|
||||
},
|
||||
[addView, filters, setActiveViewId],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
if (activeCustom) {
|
||||
updateView(activeCustom.id, { filters: { ...filters } });
|
||||
}
|
||||
}, [activeCustom, updateView, filters]);
|
||||
|
||||
const resetView = useCallback((): void => {
|
||||
if (canonicalSnapshot) {
|
||||
applyFilters(canonicalSnapshot);
|
||||
}
|
||||
}, [canonicalSnapshot, applyFilters]);
|
||||
|
||||
const removeView = useCallback(
|
||||
(id: string): void => {
|
||||
deleteView(id);
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId('all');
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
}
|
||||
},
|
||||
[deleteView, activeViewId, setActiveViewId, applyFilters],
|
||||
);
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
customViews,
|
||||
isCustomActive: !!activeCustom,
|
||||
isModified,
|
||||
viewQuery: builtinViewQuery(activeViewId),
|
||||
clientView: isClientView(activeViewId),
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
type Options,
|
||||
} from 'nuqs';
|
||||
|
||||
import {
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
} from '../filterQuery';
|
||||
import type { DashboardFilterState, UpdatedWindow } from '../types';
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
export interface UseDashboardFiltersResult {
|
||||
filters: DashboardFilterState;
|
||||
// The backend list-filter `query` string derived from the current filters.
|
||||
query: string;
|
||||
isEmpty: boolean;
|
||||
setSearch: (value: string) => void;
|
||||
setCreatedBy: (emails: string[]) => void;
|
||||
setUpdated: (window: UpdatedWindow) => void;
|
||||
// Replace the whole filter state at once — used when applying a saved view.
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
// Owns the dashboards-list filter state, synced to the URL (shareable links,
|
||||
// back/forward) and projected into the backend `query` string. Sort/order/page
|
||||
// live in their own query-param hooks; this hook is filters-only.
|
||||
export function useDashboardFilters(): UseDashboardFiltersResult {
|
||||
const [search, setSearchState] = useQueryState(
|
||||
'search',
|
||||
parseAsString.withDefault('').withOptions(opts),
|
||||
);
|
||||
const [createdBy, setCreatedByState] = useQueryState(
|
||||
'createdBy',
|
||||
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
|
||||
);
|
||||
const [updated, setUpdatedState] = useQueryState(
|
||||
'updated',
|
||||
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
|
||||
);
|
||||
|
||||
const filters = useMemo<DashboardFilterState>(
|
||||
() => ({ search, createdBy, updated }),
|
||||
[search, createdBy, updated],
|
||||
);
|
||||
|
||||
const query = useMemo(() => filterStateToQuery(filters), [filters]);
|
||||
|
||||
const setSearch = useCallback(
|
||||
(value: string): void => {
|
||||
void setSearchState(value);
|
||||
},
|
||||
[setSearchState],
|
||||
);
|
||||
|
||||
const setCreatedBy = useCallback(
|
||||
(emails: string[]): void => {
|
||||
void setCreatedByState(emails.length ? emails : null);
|
||||
},
|
||||
[setCreatedByState],
|
||||
);
|
||||
|
||||
const setUpdated = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
void setUpdatedState(window);
|
||||
},
|
||||
[setUpdatedState],
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: DashboardFilterState): void => {
|
||||
void setSearchState(next.search || null);
|
||||
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
|
||||
void setUpdatedState(next.updated);
|
||||
},
|
||||
[setSearchState, setCreatedByState, setUpdatedState],
|
||||
);
|
||||
|
||||
const clearAll = useCallback((): void => {
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
}, [applyFilters]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: isFilterStateEmpty(filters),
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import type { SavedView } from '../types';
|
||||
|
||||
// Most-recently-viewed list is capped so it stays a useful shortlist.
|
||||
const RECENT_LIMIT = 20;
|
||||
|
||||
// Client-side persistence for everything the views feature owns until the views
|
||||
// API lands: user-saved views, favorite/recently-viewed dashboard ids, and the
|
||||
// rail collapse preference. Mirrors `useDashboardsListVisibleColumnsStore`.
|
||||
interface DashboardViewsState {
|
||||
customViews: SavedView[];
|
||||
favorites: string[]; // dashboard ids
|
||||
recent: string[]; // dashboard ids, most-recent first
|
||||
railCollapsed: boolean;
|
||||
addView: (view: SavedView) => void;
|
||||
updateView: (id: string, patch: Partial<Omit<SavedView, 'id'>>) => void;
|
||||
deleteView: (id: string) => void;
|
||||
toggleFavorite: (id: string) => void;
|
||||
markViewed: (id: string) => void;
|
||||
setRailCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
customViews: [] as SavedView[],
|
||||
favorites: [] as string[],
|
||||
recent: [] as string[],
|
||||
railCollapsed: false,
|
||||
};
|
||||
|
||||
export const useDashboardViewsStore = create<DashboardViewsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
addView: (view): void => {
|
||||
set((s) => ({ customViews: [...s.customViews, view] }));
|
||||
},
|
||||
updateView: (id, patch): void => {
|
||||
set((s) => ({
|
||||
customViews: s.customViews.map((v) =>
|
||||
v.id === id ? { ...v, ...patch } : v,
|
||||
),
|
||||
}));
|
||||
},
|
||||
deleteView: (id): void => {
|
||||
set((s) => ({ customViews: s.customViews.filter((v) => v.id !== id) }));
|
||||
},
|
||||
toggleFavorite: (id): void => {
|
||||
set((s) => ({
|
||||
favorites: s.favorites.includes(id)
|
||||
? s.favorites.filter((f) => f !== id)
|
||||
: [...s.favorites, id],
|
||||
}));
|
||||
},
|
||||
markViewed: (id): void => {
|
||||
set((s) => ({
|
||||
recent: [id, ...s.recent.filter((r) => r !== id)].slice(0, RECENT_LIMIT),
|
||||
}));
|
||||
},
|
||||
setRailCollapsed: (collapsed): void => {
|
||||
set({ railCollapsed: collapsed });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: LOCALSTORAGE.DASHBOARDS_LIST_VIEWS,
|
||||
merge: (persisted, current) => ({
|
||||
...current,
|
||||
...DEFAULT_STATE,
|
||||
...((persisted as Partial<DashboardViewsState>) ?? {}),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
// Relative "updated within" windows offered by the Updated filter chip.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
|
||||
// The user-controllable filter state a view captures. (Tags are intentionally
|
||||
// excluded for now — the tag filter UI is deferred.) Sort/order are handled
|
||||
// separately via URL query params and are not part of a view snapshot.
|
||||
export interface DashboardFilterState {
|
||||
search: string;
|
||||
createdBy: string[]; // emails (created_by)
|
||||
updated: UpdatedWindow;
|
||||
}
|
||||
|
||||
// A saved view: a named, iconed snapshot of filter state. Persisted client-side
|
||||
// (localStorage) until the views API lands.
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // @signozhq/icons icon name
|
||||
filters: DashboardFilterState;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Built-in views rendered above the user's saved views. Their result set is
|
||||
// derived (a fixed query fragment or a client-side id set), never persisted.
|
||||
export type BuiltinViewId = 'mine' | 'favorites' | 'recent' | 'all' | 'locked';
|
||||
|
||||
export type ViewSection = 'personal' | 'system' | 'custom';
|
||||
@@ -1,9 +1,6 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type {
|
||||
DashboardtypesListedDashboardV2DTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
|
||||
|
||||
@@ -14,24 +11,6 @@ export const tagsToStrings = (
|
||||
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
|
||||
);
|
||||
|
||||
// Inverse of `tagsToStrings`: each comma-separated tag is "key:value" or a bare
|
||||
// label (key === value).
|
||||
export const toPostableTags = (raw: string): TagtypesPostableTagDTO[] =>
|
||||
raw
|
||||
.split(',')
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean)
|
||||
.map((label) => {
|
||||
const sep = label.indexOf(':');
|
||||
if (sep > 0) {
|
||||
return {
|
||||
key: label.slice(0, sep).trim(),
|
||||
value: label.slice(sep + 1).trim(),
|
||||
};
|
||||
}
|
||||
return { key: label, value: label };
|
||||
});
|
||||
|
||||
export const lastUpdatedLabel = (time: string | undefined): string => {
|
||||
if (!time || isEmpty(time)) {
|
||||
return 'No updates yet!';
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Built-in view catalogue + the pure logic that maps a view to how it
|
||||
// constrains the list. Views fall into three mechanisms:
|
||||
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
|
||||
// - query: contributes an extra server clause AND-ed with the chips (Locked)
|
||||
// - client: constrains by a client-side id set (Favorites, Recently viewed)
|
||||
import {
|
||||
Activity,
|
||||
Bookmark,
|
||||
Clock,
|
||||
Code,
|
||||
Flag,
|
||||
Layers,
|
||||
Lock,
|
||||
Server,
|
||||
Star,
|
||||
Tag,
|
||||
User,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { DEFAULT_FILTER_STATE } from './filterQuery';
|
||||
import type { BuiltinViewId, DashboardFilterState, ViewSection } from './types';
|
||||
import type { DashboardListItem } from './utils';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
export type ViewIcon = typeof Star;
|
||||
|
||||
export interface BuiltinView {
|
||||
id: BuiltinViewId;
|
||||
label: string;
|
||||
icon: ViewIcon;
|
||||
section: Exclude<ViewSection, 'custom'>;
|
||||
}
|
||||
|
||||
export const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
{ id: 'mine', label: 'My dashboards', icon: User, section: 'personal' },
|
||||
{ id: 'favorites', label: 'Favorites', icon: Star, section: 'personal' },
|
||||
{ id: 'recent', label: 'Recently viewed', icon: Clock, section: 'personal' },
|
||||
{ id: 'all', label: 'All dashboards', icon: Layers, section: 'system' },
|
||||
{ id: 'locked', label: 'Locked', icon: Lock, section: 'system' },
|
||||
];
|
||||
|
||||
// Icons offered when naming a saved view; stored by name on the view.
|
||||
export const VIEW_ICON_OPTIONS: { name: string; Icon: ViewIcon }[] = [
|
||||
{ name: 'bookmark', Icon: Bookmark },
|
||||
{ name: 'star', Icon: Star },
|
||||
{ name: 'layers', Icon: Layers },
|
||||
{ name: 'activity', Icon: Activity },
|
||||
{ name: 'server', Icon: Server },
|
||||
{ name: 'code', Icon: Code },
|
||||
{ name: 'flag', Icon: Flag },
|
||||
{ name: 'tag', Icon: Tag },
|
||||
{ name: 'lock', Icon: Lock },
|
||||
{ name: 'clock', Icon: Clock },
|
||||
];
|
||||
|
||||
const ICON_BY_NAME = new Map(VIEW_ICON_OPTIONS.map((o) => [o.name, o.Icon]));
|
||||
|
||||
export const iconByName = (name: string): ViewIcon =>
|
||||
ICON_BY_NAME.get(name) ?? Bookmark;
|
||||
|
||||
// Favorites/Recently-viewed constrain by a client-side id set — the backend has
|
||||
// no id filter, so these are filtered on the fetched rows.
|
||||
export const isClientView = (id: string): boolean =>
|
||||
id === 'favorites' || id === 'recent';
|
||||
|
||||
// Extra server query fragment a built-in view contributes (AND-ed with chips).
|
||||
export const builtinViewQuery = (id: string): string =>
|
||||
id === 'locked' ? 'locked = true' : '';
|
||||
|
||||
// The canonical filter snapshot a built-in view applies when selected. `null`
|
||||
// for ids that aren't built-in (custom views carry their own snapshot).
|
||||
export const builtinViewSnapshot = (
|
||||
id: string,
|
||||
userEmail: string,
|
||||
): DashboardFilterState | null => {
|
||||
switch (id) {
|
||||
case 'mine':
|
||||
return {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
createdBy: userEmail ? [userEmail] : [],
|
||||
};
|
||||
case 'all':
|
||||
case 'favorites':
|
||||
case 'recent':
|
||||
case 'locked':
|
||||
return { ...DEFAULT_FILTER_STATE };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export interface EmptyStateCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Context-aware copy for the no-results state, so an empty Locked view doesn't
|
||||
// read like a failed search ("No dashboards found for .").
|
||||
export const noResultsCopy = (
|
||||
activeViewId: string,
|
||||
search: string,
|
||||
hasActiveFilters: boolean,
|
||||
): EmptyStateCopy => {
|
||||
const trimmed = search.trim();
|
||||
if (trimmed) {
|
||||
return {
|
||||
title: `No dashboards match "${trimmed}"`,
|
||||
description: 'Try a different search term or clear your filters.',
|
||||
};
|
||||
}
|
||||
switch (activeViewId) {
|
||||
case 'favorites':
|
||||
return {
|
||||
title: 'No favorite dashboards yet',
|
||||
description: 'Star a dashboard to pin it here.',
|
||||
};
|
||||
case 'recent':
|
||||
return {
|
||||
title: 'No recently viewed dashboards',
|
||||
description: 'Dashboards you open will appear here.',
|
||||
};
|
||||
case 'locked':
|
||||
return {
|
||||
title: 'No locked dashboards',
|
||||
description: 'Dashboards locked for editing will appear here.',
|
||||
};
|
||||
case 'mine':
|
||||
return {
|
||||
title: "You haven't created any dashboards",
|
||||
description: 'Dashboards you create will appear here.',
|
||||
};
|
||||
default:
|
||||
return hasActiveFilters
|
||||
? {
|
||||
title: 'No dashboards match your filters',
|
||||
description: 'Try adjusting or clearing your filters.',
|
||||
}
|
||||
: {
|
||||
title: 'No dashboards found',
|
||||
description: 'Create a dashboard to get started.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Apply a client-side view's id-set constraint to already-fetched rows.
|
||||
// Recently-viewed preserves visit order regardless of the active sort.
|
||||
export const applyClientView = (
|
||||
items: DashboardListItem[],
|
||||
id: string,
|
||||
favorites: string[],
|
||||
recent: string[],
|
||||
): DashboardListItem[] => {
|
||||
if (id === 'favorites') {
|
||||
const set = new Set(favorites);
|
||||
return items.filter((d) => set.has(d.id));
|
||||
}
|
||||
if (id === 'recent') {
|
||||
const order = new Map(recent.map((rid, index) => [rid, index]));
|
||||
return items
|
||||
.filter((d) => order.has(d.id))
|
||||
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
}
|
||||
return items;
|
||||
};
|
||||
@@ -21,7 +21,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
@@ -37,7 +37,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
Response: nil,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
@@ -54,7 +54,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -88,7 +88,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -111,23 +111,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.UpdateMyUser), handler.OpenAPIDef{
|
||||
ID: "UpdateMyUserV2",
|
||||
Tags: []string{"users"},
|
||||
@@ -156,7 +139,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -190,7 +173,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,16 +3,15 @@ package flagger
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
var (
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -89,14 +88,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureEnableAIObservability,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether ai observability is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -25,42 +25,6 @@ func NewHandler(setter root.Setter, getter root.Getter) root.Handler {
|
||||
return &handler{setter: setter, getter: getter}
|
||||
}
|
||||
|
||||
func (handler *handler) CreateUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(authtypes.PostableUser)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := types.NewUser(req.DisplayName, req.Email, valuer.MustNewUUID(claims.OrgID), types.UserStatusPendingInvite)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleIDs := make([]valuer.UUID, 0, len(req.UserRoles))
|
||||
for _, role := range req.UserRoles {
|
||||
roleIDs = append(roleIDs, role.ID)
|
||||
}
|
||||
|
||||
user, err = handler.setter.CreatePendingInviteUser(ctx, valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), req.FrontendBaseUrl, user, root.WithRoleIDs(roleIDs))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: user.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -215,67 +215,6 @@ func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *setter) CreatePendingInviteUser(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...root.CreateUserOption) (*types.User, error) {
|
||||
if err := user.ErrIfNotPending(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createUserOpts := root.NewCreateUserOptions(opts...)
|
||||
|
||||
roleNames := createUserOpts.RoleNames
|
||||
if len(createUserOpts.RoleIDs) > 0 {
|
||||
roles, err := module.authz.ListByOrgIDAndIDs(ctx, user.OrgID, createUserOpts.RoleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
roleNames = append(roleNames, role.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var resetPasswordToken *types.ResetPasswordToken
|
||||
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.createUserWithoutGrant(ctx, user, root.WithRoleNames(roleNames), root.WithFactorPassword(createUserOpts.FactorPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resetPasswordToken = token
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), identityID.String(), "Invite Sent", map[string]any{
|
||||
"invitee_email": user.Email,
|
||||
"invitee_role": roleNames,
|
||||
})
|
||||
|
||||
if frontendBaseURL == "" {
|
||||
module.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", slog.Any("invitee_email", user.Email))
|
||||
return user, nil
|
||||
}
|
||||
|
||||
resetLink := resetPasswordToken.FactorPasswordResetLink(frontendBaseURL)
|
||||
|
||||
tokenLifetime := module.config.Password.Invite.MaxTokenLifetime
|
||||
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
|
||||
|
||||
if err := module.emailing.SendHTML(ctx, user.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
|
||||
"inviter_email": identityEmail.StringValue(),
|
||||
"link": resetLink,
|
||||
"Expiry": humanizedTokenLifetime,
|
||||
}); err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to send invite email", errors.Attr(err))
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (module *setter) UpdateUserDeprecated(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
type createUserOptions struct {
|
||||
FactorPassword *types.FactorPassword
|
||||
RoleNames []string
|
||||
RoleIDs []valuer.UUID
|
||||
}
|
||||
|
||||
type CreateUserOption func(*createUserOptions)
|
||||
@@ -25,12 +24,6 @@ func WithRoleNames(roleNames []string) CreateUserOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithRoleIDs(roleIDs []valuer.UUID) CreateUserOption {
|
||||
return func(o *createUserOptions) {
|
||||
o.RoleIDs = roleIDs
|
||||
}
|
||||
}
|
||||
|
||||
func NewCreateUserOptions(opts ...CreateUserOption) *createUserOptions {
|
||||
o := &createUserOptions{
|
||||
FactorPassword: nil,
|
||||
|
||||
@@ -45,9 +45,6 @@ type Setter interface {
|
||||
// invite
|
||||
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
|
||||
|
||||
// Creates a pending invite user with the roles given via opts and emails them the invite link.
|
||||
CreatePendingInviteUser(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...CreateUserOption) (*types.User, error)
|
||||
|
||||
// Roles
|
||||
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
|
||||
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error
|
||||
@@ -110,7 +107,6 @@ type Handler interface {
|
||||
// users
|
||||
ListUsersDeprecated(http.ResponseWriter, *http.Request)
|
||||
ListUsers(http.ResponseWriter, *http.Request)
|
||||
CreateUser(http.ResponseWriter, *http.Request)
|
||||
UpdateUserDeprecated(http.ResponseWriter, *http.Request)
|
||||
UpdateUser(http.ResponseWriter, *http.Request)
|
||||
DeleteUser(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -1678,15 +1678,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
Active: aiObservability,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -2,7 +2,6 @@ package authtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
var (
|
||||
ErrCodeUserRoleAlreadyExists = errors.MustNewCode("user_role_already_exists")
|
||||
ErrCodeUserRolesNotFound = errors.MustNewCode("user_roles_not_found")
|
||||
ErrCodeUserRoleInvalidInput = errors.MustNewCode("user_role_invalid_input")
|
||||
)
|
||||
|
||||
type UserRole struct {
|
||||
@@ -30,44 +28,6 @@ type UserRole struct {
|
||||
Role *Role `bun:"rel:belongs-to,join:role_id=id" json:"role" required:"true"`
|
||||
}
|
||||
|
||||
type UserWithRoles struct {
|
||||
*types.User
|
||||
UserRoles []*UserRole `json:"userRoles"`
|
||||
}
|
||||
|
||||
type PostableUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
UserRoles []*PostableUserRole `json:"userRoles" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type PostableUserRole struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableUser) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableUser
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.UserRoles == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserRoleInvalidInput, "userRoles is required").WithSuggestions("send an empty array to create user without role")
|
||||
}
|
||||
|
||||
for _, role := range temp.UserRoles {
|
||||
if role == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserRoleInvalidInput, "userRoles cannot contain null entries")
|
||||
}
|
||||
}
|
||||
|
||||
*p = PostableUser(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUserRole(userID valuer.UUID, roleID valuer.UUID) *UserRole {
|
||||
return &UserRole{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -88,6 +48,11 @@ func NewUserRoles(userID valuer.UUID, roles []*Role) []*UserRole {
|
||||
return userRoles
|
||||
}
|
||||
|
||||
type UserWithRoles struct {
|
||||
*types.User
|
||||
UserRoles []*UserRole `json:"userRoles"`
|
||||
}
|
||||
|
||||
type UserRoleStore interface {
|
||||
// create user roles in bulk
|
||||
CreateUserRoles(ctx context.Context, userRoles []*UserRole) error
|
||||
|
||||
@@ -24,7 +24,6 @@ var (
|
||||
ErrCodeRootUserOperationUnsupported = errors.MustNewCode("root_user_operation_unsupported")
|
||||
ErrCodeUserStatusDeleted = errors.MustNewCode("user_status_deleted")
|
||||
ErrCodeUserStatusPendingInvite = errors.MustNewCode("user_status_pending_invite")
|
||||
ErrCodeUserStatusNotPendingInvite = errors.MustNewCode("user_status_not_pending_invite")
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -215,15 +214,6 @@ func (u *User) ErrIfPending() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrIfNotPending returns an error if the user is not in pending invite state.
|
||||
// This error can be enriched with specific operation by the called using errors.WithAdditionalf.
|
||||
func (u *User) ErrIfNotPending() error {
|
||||
if u.Status != UserStatusPendingInvite {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserStatusNotPendingInvite, "operation is only supported for pending invite user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewTraitsFromUser(user *User) map[string]any {
|
||||
return map[string]any{
|
||||
"name": user.DisplayName,
|
||||
|
||||
Reference in New Issue
Block a user