Compare commits

...

9 Commits

Author SHA1 Message Date
vikrantgupta25
1947c75674 feat(user): use suggestions 2026-06-22 19:47:13 +05:30
Vikrant Gupta
cab4cd5733 Merge branch 'main' into platform-pod/issues/2279 2026-06-22 19:16:26 +05:30
vikrantgupta25
235e1aaab5 feat(user): more domain restrictions 2026-06-22 18:58:31 +05:30
Ashwin Bhatkal
4dda1e0ab5 feat(dashboards): views-first V2 dashboards list with filters, saved views, and tabbed new-dashboard modal (#11682)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboard-v2): add persisted views store, types, and filter-query helpers

* feat(dashboard-v2): add filter state hook and filter zone

* feat(dashboard-v2): add views rail with save and dirty-state flow

* feat(dashboard-v2): add list status bar with rail collapse

* feat(dashboard-v2): add favorites and recently-viewed to dashboard rows

* feat(dashboard-v2): add persisted visible-columns store

* feat(dashboard-v2): add tabbed new-dashboard modal (blank / template / import)

* feat(dashboard-v2): full-width skeleton loading state

* feat(dashboard-v2): compose views, filters, and inline metadata into the list page

* chore(dashboard-v2): remove superseded create dropdown and standalone modals

* feat(dashboard-v2): add duplicate (clone) action to dashboard rows

* refactor(dashboards-v2): move toPostableTags to utils next to its inverse

* refactor(dashboards-v2): use signoz Button for view rows & delete action

* refactor(dashboards-v2): rename filterStatesEqual to areFilterStatesEqual
2026-06-22 13:06:05 +00:00
Vikrant Gupta
5506f4eda7 Merge branch 'main' into platform-pod/issues/2279 2026-06-22 17:52:04 +05:30
vikrantgupta25
0f55e7f832 feat(user): use binding package 2026-06-22 17:46:46 +05:30
Ashwin Bhatkal
749943abe4 feat(dashboard-v2): runtime variable selection (#11646)
* feat(dashboard-v2): variable-selection store, dependency graph & sort helpers

* feat(dashboard-v2): runtime variables bar & per-type selectors

* feat(dashboard-v2): mount variables bar in dashboard toolbar
2026-06-22 11:36:43 +00:00
Tushar Vats
4f51ee37ba fix: modularize query range function (#11774) 2026-06-22 11:35:33 +00:00
vikrantgupta25
d922561dce feat(user): accept custom roles in user invite 2026-06-22 00:09:25 +05:30
79 changed files with 4872 additions and 1393 deletions

View File

@@ -659,6 +659,29 @@ 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
@@ -10183,7 +10206,7 @@ paths:
- global
/api/v1/invite:
post:
deprecated: false
deprecated: true
description: This endpoint creates an invite for a user
operationId: CreateInvite
requestBody:
@@ -10246,7 +10269,7 @@ paths:
- users
/api/v1/invite/bulk:
post:
deprecated: false
deprecated: true
description: This endpoint creates a bulk invite for a user
operationId: CreateBulkInvite
requestBody:
@@ -13087,7 +13110,7 @@ paths:
- tracedetail
/api/v1/user:
get:
deprecated: false
deprecated: true
description: This endpoint lists all users
operationId: ListUsersDeprecated
responses:
@@ -13180,7 +13203,7 @@ paths:
tags:
- users
get:
deprecated: false
deprecated: true
description: This endpoint returns the user by id
operationId: GetUserDeprecated
parameters:
@@ -13237,7 +13260,7 @@ paths:
tags:
- users
put:
deprecated: false
deprecated: true
description: This endpoint updates the user by id
operationId: UpdateUserDeprecated
parameters:
@@ -13306,7 +13329,7 @@ paths:
- users
/api/v1/user/me:
get:
deprecated: false
deprecated: true
description: This endpoint returns the user I belong to
operationId: GetMyUserDeprecated
responses:
@@ -20722,6 +20745,68 @@ 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

View File

@@ -2258,6 +2258,32 @@ 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
@@ -10807,6 +10833,14 @@ export type ListUsers200 = {
status: string;
};
export type CreateUser201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type GetUserPathParameters = {
id: string;
};

View File

@@ -18,9 +18,11 @@ import type {
} from 'react-query';
import type {
AuthtypesPostableUserDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
DeleteUserPathParameters,
GetMyUser200,
GetMyUserDeprecated200,
@@ -169,6 +171,7 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
/**
* This endpoint creates an invite for a user
* @deprecated
* @summary Create invite
*/
export const createInvite = (
@@ -230,6 +233,7 @@ export type CreateInviteMutationBody =
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create invite
*/
export const useCreateInvite = <
@@ -252,6 +256,7 @@ export const useCreateInvite = <
};
/**
* This endpoint creates a bulk invite for a user
* @deprecated
* @summary Create bulk invite
*/
export const createBulkInvite = (
@@ -313,6 +318,7 @@ export type CreateBulkInviteMutationBody =
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create bulk invite
*/
export const useCreateBulkInvite = <
@@ -418,6 +424,7 @@ export const useResetPassword = <
};
/**
* This endpoint lists all users
* @deprecated
* @summary List users
*/
export const listUsersDeprecated = (signal?: AbortSignal) => {
@@ -463,6 +470,7 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List users
*/
@@ -486,6 +494,7 @@ export function useListUsersDeprecated<
}
/**
* @deprecated
* @summary List users
*/
export const invalidateListUsersDeprecated = async (
@@ -581,6 +590,7 @@ export const useDeleteUser = <
};
/**
* This endpoint returns the user by id
* @deprecated
* @summary Get user
*/
export const getUserDeprecated = (
@@ -640,6 +650,7 @@ export type GetUserDeprecatedQueryResult = NonNullable<
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get user
*/
@@ -666,6 +677,7 @@ export function useGetUserDeprecated<
}
/**
* @deprecated
* @summary Get user
*/
export const invalidateGetUserDeprecated = async (
@@ -683,6 +695,7 @@ export const invalidateGetUserDeprecated = async (
/**
* This endpoint updates the user by id
* @deprecated
* @summary Update user
*/
export const updateUserDeprecated = (
@@ -755,6 +768,7 @@ export type UpdateUserDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Update user
*/
export const useUpdateUserDeprecated = <
@@ -783,6 +797,7 @@ export const useUpdateUserDeprecated = <
};
/**
* This endpoint returns the user I belong to
* @deprecated
* @summary Get my user
*/
export const getMyUserDeprecated = (signal?: AbortSignal) => {
@@ -828,6 +843,7 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get my user
*/
@@ -851,6 +867,7 @@ export function useGetMyUserDeprecated<
}
/**
* @deprecated
* @summary Get my user
*/
export const invalidateGetMyUserDeprecated = async (
@@ -1209,6 +1226,89 @@ 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

View File

@@ -43,4 +43,5 @@ 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',
}

View File

@@ -20,7 +20,7 @@ import APIError from 'types/api/error';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import VariablesBar from '../VariablesBar/VariablesBar';
import styles from './DashboardPageToolbar.module.scss';
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
// drives the public-access badge.
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublicDashboard}
isPublicDashboard={false}
isDashboardLocked={isDashboardLocked}
isEditing={isEditing}
draft={draft}
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onOpenRename={startEdit}
/>
</div>
<VariablesBar dashboard={dashboard} />
</section>
);
}

View File

@@ -1,3 +1,4 @@
import { sortBy } from 'lodash-es';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
@@ -76,3 +77,26 @@ export function emptyVariableFormModel(): VariableFormModel {
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;
}

View File

@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
};
return (
<div
className={styles.variableItem}
data-testid={`variable-${variable.name}`}
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size="md" />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
</div>
);
}
export default VariableSelector;

View File

@@ -0,0 +1,71 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
}
.variableItem {
display: flex;
align-items: center;
}
.variableName {
display: flex;
min-width: 56px;
height: 32px;
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 16px;
white-space: nowrap;
}
.infoIcon {
margin-left: 4px;
color: var(--l2-foreground);
}
.variableValue {
display: flex;
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l1-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;
:global(.ant-select-selector),
:global(.ant-input),
&:global(.ant-input) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
}

View File

@@ -0,0 +1,45 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
if (variables.length === 0) {
return null;
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}
export default VariablesBar;

View File

@@ -0,0 +1,56 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelectionMap } from './selectionTypes';
function formatQueryValue(val: string): string {
const num = Number(val);
if (!Number.isNaN(num) && Number.isFinite(num)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter expression from the OTHER dynamic variables' current
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
* dynamic variable's option list is scoped by its sibling selections. Variables
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
* the V1 dynamic-variable runtime.
*/
export function buildExistingDynamicVariableQuery(
variables: VariableFormModel[],
selections: VariableSelectionMap,
currentName: string,
): string {
const parts: string[] = [];
variables.forEach((variable) => {
if (
variable.name === currentName ||
variable.type !== 'DYNAMIC' ||
!variable.dynamicAttribute
) {
return;
}
const selection = selections[variable.name];
if (!selection || selection.allSelected) {
return;
}
const raw = Array.isArray(selection.value)
? selection.value
: [selection.value];
const valid = raw
.filter((v) => v !== null && v !== undefined && v !== '')
.map((v) => String(v));
if (valid.length > 0) {
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
}
});
return parts.join(' AND ');
}

View File

@@ -0,0 +1,16 @@
/** A user-selected variable value at runtime (not persisted to the spec). */
export type SelectedVariableValue =
| string
| number
| boolean
| (string | number | boolean)[]
| null;
export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
}
/** Selected values for a dashboard's variables, keyed by variable name. */
export type VariableSelectionMap = Record<string, VariableSelection>;

View File

@@ -0,0 +1,31 @@
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
export function isResolved(selection?: VariableSelection): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
return true;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,
): Record<string, SelectedVariableValue> {
const payload: Record<string, SelectedVariableValue> = {};
Object.entries(selection).forEach(([name, sel]) => {
payload[name] = sel.value;
});
return payload;
}

View File

@@ -0,0 +1,82 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
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 type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
/** Configured default; an emptied input falls back to it (V1 behaviour). */
defaultValue?: string;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Free-text variable input. Mirrors V1: edits are local and only committed on
* blur / Enter (not per keystroke), and clearing the field restores the default.
*/
function TextSelector({
selection,
defaultValue,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const [value, setValue] = useState<string>(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
const commit = useCallback(
(next: string): void => onChange({ value: next, allSelected: false }),
[onChange],
);
const handleBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const trimmed = event.target.value.trim();
if (!trimmed && defaultValue) {
setValue(defaultValue);
commit(defaultValue);
} else {
commit(trimmed);
}
},
[commit, defaultValue],
);
return (
<Input
ref={inputRef}
className={styles.control}
bordered={false}
placeholder="Enter value"
value={value}
title={value}
onChange={(e): void => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
inputRef.current?.blur();
}
}}
data-testid={testId}
/>
);
}
export default TextSelector;

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface ValueSelectorProps {
options: string[];
multiSelect: boolean;
showAllOption: boolean;
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
*/
function ValueSelector({
options,
multiSelect,
showAllOption,
loading,
selection,
onChange,
testId,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
);
if (multiSelect) {
const value = selection.allSelected
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.control}
data-testid={testId}
options={optionData}
value={value}
loading={loading}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}
return (
<CustomSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={
selection.value == null || Array.isArray(selection.value)
? undefined
: String(selection.value)
}
loading={loading}
showSearch
placeholder="Select value"
onChange={(next): void =>
onChange({ value: next == null ? '' : String(next), allSelected: false })
}
/>
);
}
export default ValueSelector;

View File

@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
import type { VariableSelection } from './selectionTypes';
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option — so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo } from 'react';
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 { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
return { value: def, allSelected: false };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
/**
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
}

View File

@@ -0,0 +1,199 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
* "depends on" another variable when its query text references that variable
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
* changes, its dependent QUERY variables must refetch. Ported from the V1
* dashboard-variables runtime; operates on the V2 flat variable model.
*/
export type VariableGraph = Record<string, string[]>;
export interface VariableDependencyData {
/** Topological order of variables (parents before children). */
order: string[];
/** Direct children (dependents) of each variable. */
graph: VariableGraph;
/** Direct parents of each variable. */
parentGraph: VariableGraph;
/** All transitive descendants of each variable (precomputed). */
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
/** Names of QUERY variables whose query references `variableName`. */
function getDependents(
variableName: string,
variables: VariableFormModel[],
): string[] {
return variables
.filter(
(v) =>
v.type === 'QUERY' &&
!!v.name &&
textContainsVariableReference(v.queryValue || '', variableName),
)
.map((v) => v.name);
}
/** variable name → its direct dependents (children). */
export function buildDependencies(
variables: VariableFormModel[],
): VariableGraph {
const graph: VariableGraph = {};
variables.forEach((v) => {
if (v.name) {
graph[v.name] = getDependents(v.name, variables);
}
});
return graph;
}
/** Invert a child graph into a parent graph. */
export function buildParentGraph(graph: VariableGraph): VariableGraph {
const parents: VariableGraph = {};
Object.keys(graph).forEach((node) => {
parents[node] = parents[node] ?? [];
});
Object.entries(graph).forEach(([node, children]) => {
children.forEach((child) => {
parents[child] = parents[child] ?? [];
parents[child].push(node);
});
});
return parents;
}
function collectCyclePath(
graph: VariableGraph,
start: string,
end: string,
): string[] {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
}
function detectCycle(
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
let cycleNodes: string[] | null = null;
(graph[node] || []).some((neighbor) => {
if (!visited.has(neighbor)) {
const found = detectCycle(graph, neighbor, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
} else if (recStack.has(neighbor)) {
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
}
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
export function buildDependencyData(
dependencies: VariableGraph,
): VariableDependencyData {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
Object.keys(dependencies).forEach((node) => {
inDegree[node] = inDegree[node] ?? 0;
adjList[node] = adjList[node] ?? [];
(dependencies[node] || []).forEach((child) => {
inDegree[child] = inDegree[child] ?? 0;
inDegree[child] += 1;
adjList[node].push(child);
});
});
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const found = detectCycle(dependencies, node, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
}
return false;
});
// Topological sort (Kahn's algorithm).
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
const order: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
order.push(current);
(adjList[current] || []).forEach((neighbor) => {
inDegree[neighbor] -= 1;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = order.length !== Object.keys(dependencies).length;
// Transitive descendants: walk topo order in reverse.
const transitiveDescendants: VariableGraph = {};
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
const desc = new Set<string>();
(adjList[node] || []).forEach((child) => {
desc.add(child);
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
});
transitiveDescendants[node] = Array.from(desc);
}
return {
order,
graph: adjList,
parentGraph: buildParentGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/** Compute the full dependency data straight from the variable list. */
export function computeVariableDependencies(
variables: VariableFormModel[],
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}

View File

@@ -0,0 +1,55 @@
import type { StateCreator } from 'zustand';
import type {
VariableSelection,
VariableSelectionMap,
} from '../../VariablesBar/selectionTypes';
import type { DashboardStore } from '../useDashboardStore';
/**
* Runtime variable selection — the values the user picks in the variable bar.
* Keyed by dashboardId → variable name. Frontend-only and persisted to
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
setVariableValue: (
dashboardId: string,
name: string,
selection: VariableSelection,
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
}
export const createVariableSelectionSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
variableValues: {
...variableValues,
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
},
});
},
setVariableValues: (dashboardId, values): void => {
const { variableValues } = get();
set({
variableValues: { ...variableValues, [dashboardId]: values },
});
},
});
/** Selector: the selection map for a dashboard (empty if none). */
export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? {};

View File

@@ -9,25 +9,36 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
* - variable-selection: runtime variable values (frontend-only, persisted).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
},
),
);

View File

@@ -1,17 +1,20 @@
.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 8px;
padding: 0 16px;
gap: 8px;
height: 48px;
flex: none;
border-bottom: 1px solid var(--l2-border);
}
.headerLeft {

View File

@@ -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';
import DashboardsList from './components/DashboardsList/DashboardsList';
import styles from './DashboardsListPageV2.module.scss';
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
function DashboardsListPageV2(): JSX.Element {
const [showBanner, setShowBanner] = useState(true);
@@ -24,8 +24,7 @@ function DashboardsListPageV2(): JSX.Element {
)}
<div className={styles.header}>
<div className={styles.headerLeft}>
<LayoutGrid size={14} className={styles.icon} />
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>
</div>
<HeaderRightSection
enableAnnouncements={false}

View File

@@ -1,12 +1,21 @@
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';
@@ -31,6 +40,23 @@ 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
@@ -71,6 +97,20 @@ 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}

View File

@@ -1,164 +0,0 @@
.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;
}
}

View File

@@ -1,218 +0,0 @@
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;

View File

@@ -1,34 +0,0 @@
.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;
}

View File

@@ -1,119 +0,0 @@
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;

View File

@@ -1,9 +1,14 @@
.row {
padding: 12px 16px 16px 16px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-top: none;
background: var(--l2-background);
background: var(--l1-background);
cursor: pointer;
transition: background 0.12s;
}
.row:hover {
background: var(--l2-background);
}
.titleWithAction {
@@ -57,6 +62,40 @@
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;

View File

@@ -1,7 +1,8 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock } from '@signozhq/icons';
import { CalendarClock, Star } from '@signozhq/icons';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
@@ -11,6 +12,7 @@ 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';
@@ -35,6 +37,12 @@ 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];
@@ -53,6 +61,7 @@ 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,
@@ -60,6 +69,11 @@ function DashboardRow({
});
};
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
event.stopPropagation();
toggleFavorite(id);
};
return (
<div className={styles.row} onClick={onClickHandler}>
<div className={styles.titleWithAction}>
@@ -98,6 +112,17 @@ 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}

View File

@@ -0,0 +1,32 @@
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;

View File

@@ -1,14 +1,43 @@
.container {
margin-top: 30px;
margin-bottom: 30px;
.layout {
display: flex;
justify-content: center;
align-items: stretch;
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: calc(100% - 30px);
max-width: 836px;
width: 100%;
:global(.ant-table-wrapper) :global(.ant-table-cell) {
padding: 0 !important;
@@ -16,14 +45,6 @@
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)
@@ -55,19 +76,43 @@
}
}
.titleContainer {
.commandHeader {
display: flex;
flex-direction: column;
gap: 4px;
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;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
@@ -80,17 +125,16 @@
}
.integrationsContainer {
margin: 16px 0;
width: 100%;
}
.integrationsContent {
max-width: 100%;
width: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0;
// 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;
}
}

View File

@@ -1,55 +1,45 @@
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 {
createDashboardV2,
useListDashboardsV2,
} from 'api/generated/services/dashboard';
import { 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 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 { 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 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();
@@ -58,38 +48,100 @@ function DashboardsList(): JSX.Element {
user.role,
);
const [searchString, setSearchString] = useSearch();
const {
filters,
query,
isEmpty: filtersEmpty,
setSearch,
setCreatedBy,
setUpdated,
applyFilters,
clearAll,
} = useDashboardFilters();
const [sortColumn, setSortColumn] = useSortColumn();
const [sortOrder, setSortOrder] = useSortOrder();
const [page, setPage] = usePage();
const [searchInput, setSearchInput] = useState(searchString);
const {
activeViewId,
builtinViews,
customViews,
isCustomActive,
isModified,
viewQuery,
clientView,
selectView,
saveView,
saveActiveView,
resetView,
removeView,
} = useActiveView({ filters, applyFilters, userEmail: user.email });
// 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]);
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
const favorites = useDashboardViewsStore((s) => s.favorites);
const recent = useDashboardViewsStore((s) => s.recent);
const handleSubmitSearch = useCallback((): void => {
const next = searchInput.trim();
if (next === searchString) {
return;
}
void setSearchString(next);
// 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();
void setPage(1);
}, [searchInput, searchString, setSearchString, setPage]);
}, [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]);
const listParams = useMemo(
() => ({
query: searchString.trim() || undefined,
query: combineQueries(viewQuery, query) || undefined,
sort: sortColumn,
order: sortOrder,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
}),
[searchString, sortColumn, sortOrder, page],
[viewQuery, query, sortColumn, sortOrder, page, clientView],
);
const {
@@ -107,52 +159,49 @@ function DashboardsList(): JSX.Element {
const errorHttpStatus = apiError?.getHttpStatusCode();
const errorMessage = apiError?.getErrorMessage();
const dashboards = useMemo<DashboardListItem[]>(
const rawDashboards = useMemo<DashboardListItem[]>(
() => response?.data?.dashboards ?? [],
[response],
);
const total = response?.data?.total ?? 0;
const [isImportOpen, setIsImportOpen] = useState(false);
const [isConfigureOpen, setIsConfigureOpen] = useState(false);
// 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 visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
);
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 openCreate = useCallback((): void => {
logEvent('Dashboard List: New dashboard clicked', {});
setIsCreateOpen(true);
}, []);
const onSortChange = useCallback(
@@ -180,102 +229,109 @@ 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.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 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>
</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
<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}
sortColumn={sortColumn}
onSortChange={onSortChange}
sortOrder={sortOrder}
onOrderChange={onOrderChange}
onConfigureMetadata={(): void => setIsConfigureOpen(true)}
/>
<DashboardsListContent
dashboards={dashboards}
page={page}
pageSize={PAGE_SIZE}
pageSize={clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE}
total={total}
onPageChange={setPage}
canAct={!!action}
showUpdatedAt={visibleColumns.updatedAt}
showUpdatedBy={visibleColumns.updatedBy}
loading={creating || isFetching}
loading={isFetching}
/>
</>
)}
</>
)}
<ImportJSONModal
open={isImportOpen}
onClose={(): void => setIsImportOpen(false)}
/>
<ConfigureMetadataModal
open={isConfigureOpen}
previewDashboard={dashboards[0]}
onClose={(): void => setIsConfigureOpen(false)}
</div>
</>
)}
</div>
<StatusBar
collapsed={railCollapsed}
onToggleCollapse={toggleRail}
count={dashboards.length}
total={total}
/>
</div>
<NewDashboardModal
open={isCreateOpen}
onClose={(): void => setIsCreateOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
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;

View File

@@ -0,0 +1,22 @@
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;

View File

@@ -0,0 +1,23 @@
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;

View File

@@ -1,3 +0,0 @@
import DashboardsList from './DashboardsList';
export default DashboardsList;

View File

@@ -0,0 +1,129 @@
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;

View File

@@ -0,0 +1,50 @@
.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;
}

View File

@@ -0,0 +1,94 @@
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;

View File

@@ -1,73 +0,0 @@
.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);
}
}

View File

@@ -1,223 +0,0 @@
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')}&nbsp;
<ExternalLink size={14} />
</Button>
</a>
</Flex>
<Button
onClick={handleImport}
loading={isCreating}
className="periscope-btn primary"
type="primary"
>
{t('import_and_next')} &nbsp; <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;

View File

@@ -1,154 +0,0 @@
{
"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"
}
}
]
}
}
]
}

View File

@@ -6,9 +6,8 @@
height: 44px;
flex-shrink: 0;
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
border: 1px solid var(--l2-border);
background: var(--l1-background);
}
.label {
@@ -23,10 +22,36 @@
.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.

View File

@@ -1,17 +1,20 @@
import { Button, Popover, Tooltip } from 'antd';
// 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 { Typography } from '@signozhq/ui/typography';
import {
ArrowDownWideNarrow,
Check,
Ellipsis,
HdmiPort,
} from '@signozhq/icons';
import { ArrowDown, ArrowUp, Check, 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 {
@@ -19,131 +22,178 @@ 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}>All Dashboards</Typography.Text>
<Typography.Text className={styles.label}>Results</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.configureContent}>
<button
type="button"
className={styles.configureItem}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onConfigureMetadata();
}}
data-testid="configure-metadata-trigger"
<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
}
>
<span className={styles.configureIcon}>
<HdmiPort size={14} />
</span>
<span>Configure metadata</span>
</button>
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>
</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}
>
<button
type="button"
className={styles.iconTrigger}
aria-label="More options"
>
<Ellipsis size={14} />
</button>
<Tooltip title="Metadata">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Metadata"
testId="configure-metadata-trigger"
>
<HdmiPort size={14} />
</Button>
</Tooltip>
</Popover>
</section>
</div>

View File

@@ -0,0 +1,148 @@
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;

View File

@@ -0,0 +1,132 @@
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;

View File

@@ -0,0 +1,90 @@
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;

View File

@@ -0,0 +1,195 @@
// 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;
}

View File

@@ -0,0 +1,59 @@
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;

View File

@@ -0,0 +1,139 @@
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;

View File

@@ -0,0 +1,106 @@
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,
});
}

View File

@@ -9,12 +9,18 @@ interface Props {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
}
function SearchBar({ value, onChange, onSubmit }: Props): JSX.Element {
function SearchBar({
value,
onChange,
onSubmit,
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
}: Props): JSX.Element {
return (
<Input
placeholder="Search with DSL (e.g. name CONTAINS 'foo')"
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<button

View File

@@ -0,0 +1,15 @@
.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);
}

View File

@@ -0,0 +1,41 @@
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;

View File

@@ -0,0 +1,110 @@
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;

View File

@@ -0,0 +1,256 @@
.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;
}

View File

@@ -0,0 +1,283 @@
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 &ldquo;{query}&rdquo;
</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;

View File

@@ -1,11 +1,18 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
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;
}
}

View File

@@ -2,13 +2,14 @@ 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}>
<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} />
{ROWS.map((row) => (
<Skeleton.Input key={row} active block className={styles.skeleton} />
))}
</div>
);
}

View File

@@ -3,3 +3,15 @@
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;
}

View File

@@ -5,16 +5,20 @@ import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './NoResultsState.module.scss';
interface Props {
searchString: string;
title: string;
description?: string;
}
function NoResultsState({ searchString }: Props): JSX.Element {
function NoResultsState({ title, description }: Props): JSX.Element {
return (
<div className={styles.wrapper}>
<img src={emptyStateUrl} alt="img" height={32} width={32} />
<Typography.Text>
No dashboards found for {searchString}. Create a new dashboard?
</Typography.Text>
<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>
)}
</div>
);
}

View File

@@ -0,0 +1,85 @@
// 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(',');

View File

@@ -0,0 +1,142 @@
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,
};
}

View File

@@ -0,0 +1,102 @@
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,
};
}

View File

@@ -0,0 +1,75 @@
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>) ?? {}),
}),
},
),
);

View File

@@ -0,0 +1,27 @@
// 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';

View File

@@ -1,6 +1,9 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListedDashboardV2DTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
@@ -11,6 +14,24 @@ 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!';

View File

@@ -0,0 +1,164 @@
// 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;
};

View File

@@ -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: false,
Deprecated: true,
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: false,
Deprecated: true,
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: false,
Deprecated: true,
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: false,
Deprecated: true,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
@@ -111,6 +111,23 @@ 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"},
@@ -139,7 +156,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
@@ -173,7 +190,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err

View File

@@ -25,6 +25,42 @@ 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()

View File

@@ -215,6 +215,67 @@ 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 {

View File

@@ -8,6 +8,7 @@ import (
type createUserOptions struct {
FactorPassword *types.FactorPassword
RoleNames []string
RoleIDs []valuer.UUID
}
type CreateUserOption func(*createUserOptions)
@@ -24,6 +25,12 @@ 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,

View File

@@ -45,6 +45,9 @@ 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
@@ -107,6 +110,7 @@ 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)

View File

@@ -92,11 +92,6 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
event := &qbtypes.QBEvent{
Version: "v5",
NumberOfQueries: len(req.CompositeQuery.Queries),
@@ -116,9 +111,6 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
// We need to set if it is unspecified or adjust it if value is not within recommended range
intervalWarnings := q.adjustStepInterval(req.CompositeQuery.Queries, req.Start, req.End)
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
if err != nil {
return nil, err
@@ -128,6 +120,64 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
missingMetricQuerySet[name] = true
}
queries, steps, err := q.buildQueries(req, dependencyQueries, missingMetricQuerySet, event)
if err != nil {
return nil, err
}
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries {
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
case qbtypes.RequestTypeScalar:
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
case qbtypes.RequestTypeRaw:
preseededResults[name] = &qbtypes.RawData{QueryName: name}
}
}
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event, preseededResults)
if qbResp != nil {
qbResp.QBEvent = event
if len(intervalWarnings) != 0 && req.RequestType == qbtypes.RequestTypeTimeSeries {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{
Warnings: make([]qbtypes.QueryWarnDataAdditional, len(intervalWarnings)),
}
for idx := range intervalWarnings {
qbResp.Warning.Warnings[idx] = qbtypes.QueryWarnDataAdditional{Message: intervalWarnings[idx]}
}
}
}
if len(metricWarnings) > 0 {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{}
}
for _, w := range metricWarnings {
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: w,
})
}
}
}
return qbResp, qbErr
}
func (q *querier) buildQueries(
req *qbtypes.QueryRangeRequest,
dependencyQueries map[string]bool,
missingMetricQuerySet map[string]bool,
event *qbtypes.QBEvent,
) (map[string]qbtypes.Query, map[string]qbtypes.Step, error) {
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
for _, query := range req.CompositeQuery.Queries {
queryName := query.GetQueryName()
@@ -140,7 +190,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
case qbtypes.QueryTypePromQL:
promQuery, ok := query.Spec.(qbtypes.PromQuery)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
}
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
queries[promQuery.Name] = promqlQuery
@@ -148,14 +198,14 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
case qbtypes.QueryTypeClickHouseSQL:
chQuery, ok := query.Spec.(qbtypes.ClickHouseQuery)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
}
chSQLQuery := newchSQLQuery(q.logger, q.telemetryStore, chQuery, nil, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
queries[chQuery.Name] = chSQLQuery
case qbtypes.QueryTypeTraceOperator:
traceOpQuery, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
}
toq := &traceOperatorQuery{
telemetryStore: q.telemetryStore,
@@ -208,46 +258,12 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
}
}
}
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries {
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
case qbtypes.RequestTypeScalar:
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
case qbtypes.RequestTypeRaw:
preseededResults[name] = &qbtypes.RawData{QueryName: name}
}
}
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event, preseededResults)
if qbResp != nil {
qbResp.QBEvent = event
if len(intervalWarnings) != 0 && req.RequestType == qbtypes.RequestTypeTimeSeries {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{
Warnings: make([]qbtypes.QueryWarnDataAdditional, len(intervalWarnings)),
}
for idx := range intervalWarnings {
qbResp.Warning.Warnings[idx] = qbtypes.QueryWarnDataAdditional{Message: intervalWarnings[idx]}
}
}
}
if len(metricWarnings) > 0 {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{}
}
for _, w := range metricWarnings {
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: w,
})
}
}
}
return qbResp, qbErr
return queries, steps, nil
}
func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.QueryEnvelope) {

View File

@@ -2,6 +2,7 @@ package authtypes
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -13,6 +14,7 @@ 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 {
@@ -28,6 +30,38 @@ 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")
}
*p = PostableUser(temp)
return nil
}
func newUserRole(userID valuer.UUID, roleID valuer.UUID) *UserRole {
return &UserRole{
ID: valuer.GenerateUUID(),
@@ -48,11 +82,6 @@ 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

View File

@@ -24,6 +24,7 @@ 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 (
@@ -214,6 +215,15 @@ 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,