Compare commits

...

8 Commits

Author SHA1 Message Date
aks07
9622c867a2 feat: add pagination to list view 2026-06-08 23:38:34 +05:30
aks07
a2e75cf5ba Merge branch 'main' of github.com:SigNoz/signoz into feat/traces-table-migration 2026-06-08 15:15:03 +05:30
Ashwin Bhatkal
0948a983c3 feat(dashboards): V2 dashboard — settings, configure drawer & inline title (#11581)
* refactor(dashboard-v2): name props interfaces by component (Props → <Component>Props)

* feat(dashboard-v2): shared header chrome + confirm-delete dialog

* feat(dashboard-v2): dashboard settings drawer — general, variables/publish tabs

* feat(dashboard-v2): sections & panels — empty states, menus, theming, review fixes

* feat(dashboard-v2): dashboard header — inline-editable title, actions menu

* feat(dashboard-v2): container wiring + new-panel flow
2026-06-08 08:13:06 +00:00
primus-bot[bot]
4f7ebd1ff1 chore(release): bump to v0.127.1 (#11606)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-07 16:25:54 +00:00
Aditya Singh
15fbe4f788 fix(frontend): dropdown menu rendering behind antd drawer/modal (z-index regression) (#11604)
* feat: update dropdown menu content z-index to match antd base z-index

* feat: align dropdown to start
2026-06-07 14:26:08 +00:00
Srikanth Chekuri
7da7fda283 chore(alertmanager): support custom receiver configs (#11473)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(alertmanager): support custom receiver configs

* chore: update

* chore: update types

* chore: regenerate

* chore: copy type and function
2026-06-06 17:34:35 +00:00
swapnil-signoz
9093b4c442 fix: service integration tests fix (#11598)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-05 18:17:36 +00:00
aks07
b2cba2aa2c feat: trace view pagination init 2026-06-05 19:04:16 +05:30
97 changed files with 3176 additions and 1376 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.127.0
image: signoz/signoz:v0.127.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.127.0
image: signoz/signoz:v0.127.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.127.0}
image: signoz/signoz:${VERSION:-v0.127.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.127.0}
image: signoz/signoz:${VERSION:-v0.127.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -96,6 +96,19 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesGoogleChatReceiverConfig:
properties:
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
send_resolved:
type: boolean
text:
type: string
title:
type: string
webhook_url:
$ref: '#/components/schemas/ConfigSecretURL'
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -147,6 +160,8 @@ components:
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
- googlechat_configs
- required:
- discord_configs
- required:
@@ -192,6 +207,10 @@ components:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
googlechat_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesGoogleChatReceiverConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
@@ -305,6 +324,87 @@ components:
- channels
- name
type: object
AlertmanagertypesReceiver:
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
googlechat_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesGoogleChatReceiverConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
type: object
AlertmanagertypesRecurrence:
properties:
duration:
@@ -1865,83 +1965,6 @@ components:
user_key_file:
type: string
type: object
ConfigReceiver:
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
type: object
ConfigRocketchatAttachmentAction:
properties:
image_url:
@@ -7691,7 +7714,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigReceiver'
$ref: '#/components/schemas/AlertmanagertypesReceiver'
responses:
"204":
description: No Content
@@ -7742,7 +7765,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigReceiver'
$ref: '#/components/schemas/AlertmanagertypesReceiver'
responses:
"204":
description: No Content
@@ -12480,7 +12503,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigReceiver'
$ref: '#/components/schemas/AlertmanagertypesReceiver'
responses:
"204":
description: No Content

View File

@@ -19,7 +19,7 @@ import type {
import type {
AlertmanagertypesPostableChannelDTO,
ConfigReceiverDTO,
AlertmanagertypesReceiverDTO,
CreateChannel201,
DeleteChannelByIDPathParameters,
GetChannelByID200,
@@ -385,14 +385,14 @@ export const invalidateGetChannelByID = async (
*/
export const updateChannelByID = (
{ id }: UpdateChannelByIDPathParameters,
configReceiverDTO?: BodyType<ConfigReceiverDTO>,
alertmanagertypesReceiverDTO?: BodyType<AlertmanagertypesReceiverDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/channels/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: configReceiverDTO,
data: alertmanagertypesReceiverDTO,
signal,
});
};
@@ -406,7 +406,7 @@ export const getUpdateChannelByIDMutationOptions = <
TError,
{
pathParams: UpdateChannelByIDPathParameters;
data?: BodyType<ConfigReceiverDTO>;
data?: BodyType<AlertmanagertypesReceiverDTO>;
},
TContext
>;
@@ -415,7 +415,7 @@ export const getUpdateChannelByIDMutationOptions = <
TError,
{
pathParams: UpdateChannelByIDPathParameters;
data?: BodyType<ConfigReceiverDTO>;
data?: BodyType<AlertmanagertypesReceiverDTO>;
},
TContext
> => {
@@ -432,7 +432,7 @@ export const getUpdateChannelByIDMutationOptions = <
Awaited<ReturnType<typeof updateChannelByID>>,
{
pathParams: UpdateChannelByIDPathParameters;
data?: BodyType<ConfigReceiverDTO>;
data?: BodyType<AlertmanagertypesReceiverDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -447,7 +447,7 @@ export type UpdateChannelByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateChannelByID>>
>;
export type UpdateChannelByIDMutationBody =
| BodyType<ConfigReceiverDTO>
| BodyType<AlertmanagertypesReceiverDTO>
| undefined;
export type UpdateChannelByIDMutationError = ErrorType<RenderErrorResponseDTO>;
@@ -463,7 +463,7 @@ export const useUpdateChannelByID = <
TError,
{
pathParams: UpdateChannelByIDPathParameters;
data?: BodyType<ConfigReceiverDTO>;
data?: BodyType<AlertmanagertypesReceiverDTO>;
},
TContext
>;
@@ -472,7 +472,7 @@ export const useUpdateChannelByID = <
TError,
{
pathParams: UpdateChannelByIDPathParameters;
data?: BodyType<ConfigReceiverDTO>;
data?: BodyType<AlertmanagertypesReceiverDTO>;
},
TContext
> => {
@@ -483,14 +483,14 @@ export const useUpdateChannelByID = <
* @summary Test notification channel
*/
export const testChannel = (
configReceiverDTO?: BodyType<ConfigReceiverDTO>,
alertmanagertypesReceiverDTO?: BodyType<AlertmanagertypesReceiverDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/channels/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: configReceiverDTO,
data: alertmanagertypesReceiverDTO,
signal,
});
};
@@ -502,13 +502,13 @@ export const getTestChannelMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testChannel>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testChannel>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
> => {
const mutationKey = ['testChannel'];
@@ -522,7 +522,7 @@ export const getTestChannelMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testChannel>>,
{ data?: BodyType<ConfigReceiverDTO> }
{ data?: BodyType<AlertmanagertypesReceiverDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -535,7 +535,9 @@ export const getTestChannelMutationOptions = <
export type TestChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof testChannel>>
>;
export type TestChannelMutationBody = BodyType<ConfigReceiverDTO> | undefined;
export type TestChannelMutationBody =
| BodyType<AlertmanagertypesReceiverDTO>
| undefined;
export type TestChannelMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -548,13 +550,13 @@ export const useTestChannel = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testChannel>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testChannel>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
> => {
return useMutation(getTestChannelMutationOptions(options));
@@ -565,14 +567,14 @@ export const useTestChannel = <
* @summary Test notification channel (deprecated)
*/
export const testChannelDeprecated = (
configReceiverDTO?: BodyType<ConfigReceiverDTO>,
alertmanagertypesReceiverDTO?: BodyType<AlertmanagertypesReceiverDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/testChannel`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: configReceiverDTO,
data: alertmanagertypesReceiverDTO,
signal,
});
};
@@ -584,13 +586,13 @@ export const getTestChannelDeprecatedMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testChannelDeprecated>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testChannelDeprecated>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
> => {
const mutationKey = ['testChannelDeprecated'];
@@ -604,7 +606,7 @@ export const getTestChannelDeprecatedMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testChannelDeprecated>>,
{ data?: BodyType<ConfigReceiverDTO> }
{ data?: BodyType<AlertmanagertypesReceiverDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -618,7 +620,7 @@ export type TestChannelDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof testChannelDeprecated>>
>;
export type TestChannelDeprecatedMutationBody =
| BodyType<ConfigReceiverDTO>
| BodyType<AlertmanagertypesReceiverDTO>
| undefined;
export type TestChannelDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
@@ -634,13 +636,13 @@ export const useTestChannelDeprecated = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testChannelDeprecated>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testChannelDeprecated>>,
TError,
{ data?: BodyType<ConfigReceiverDTO> },
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
TContext
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));

View File

@@ -134,113 +134,6 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
updatedBy?: string | null;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum AlertmanagertypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export enum AlertmanagertypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum AlertmanagertypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface AlertmanagertypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: string;
recurrence?: AlertmanagertypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: string;
/**
* @type string
*/
timezone: string;
}
export interface AlertmanagertypesPlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesMaintenanceKindDTO;
/**
* @type string
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface ConfigAuthorizationDTO {
/**
* @type string
@@ -475,6 +368,130 @@ export interface ConfigSecretURLDTO {
[key: string]: unknown;
}
export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
webhook_url?: ConfigSecretURLDTO;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum AlertmanagertypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export enum AlertmanagertypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum AlertmanagertypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface AlertmanagertypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: string;
recurrence?: AlertmanagertypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: string;
/**
* @type string
*/
timezone: string;
}
export interface AlertmanagertypesPlannedMaintenanceDTO {
/**
* @type array,null
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesMaintenanceKindDTO;
/**
* @type string
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface ConfigDiscordConfigDTO {
/**
* @type string
@@ -1634,6 +1651,10 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
* @type array
*/
email_configs?: ConfigEmailConfigDTO[];
/**
* @type array
*/
googlechat_configs?: AlertmanagertypesGoogleChatReceiverConfigDTO[];
/**
* @type array
*/
@@ -1748,6 +1769,89 @@ export interface AlertmanagertypesPostableRoutePolicyDTO {
tags?: string[] | null;
}
export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
discord_configs?: ConfigDiscordConfigDTO[];
/**
* @type array
*/
email_configs?: ConfigEmailConfigDTO[];
/**
* @type array
*/
googlechat_configs?: AlertmanagertypesGoogleChatReceiverConfigDTO[];
/**
* @type array
*/
incidentio_configs?: ConfigIncidentioConfigDTO[];
/**
* @type array
*/
jira_configs?: ConfigJiraConfigDTO[];
/**
* @type array
*/
mattermost_configs?: ConfigMattermostConfigDTO[];
/**
* @type array
*/
msteams_configs?: ConfigMSTeamsConfigDTO[];
/**
* @type array
*/
msteamsv2_configs?: ConfigMSTeamsV2ConfigDTO[];
/**
* @type string
*/
name?: string;
/**
* @type array
*/
opsgenie_configs?: ConfigOpsGenieConfigDTO[];
/**
* @type array
*/
pagerduty_configs?: ConfigPagerdutyConfigDTO[];
/**
* @type array
*/
pushover_configs?: ConfigPushoverConfigDTO[];
/**
* @type array
*/
rocketchat_configs?: ConfigRocketchatConfigDTO[];
/**
* @type array
*/
slack_configs?: ConfigSlackConfigDTO[];
/**
* @type array
*/
sns_configs?: ConfigSNSConfigDTO[];
/**
* @type array
*/
telegram_configs?: ConfigTelegramConfigDTO[];
/**
* @type array
*/
victorops_configs?: ConfigVictorOpsConfigDTO[];
/**
* @type array
*/
webex_configs?: ConfigWebexConfigDTO[];
/**
* @type array
*/
webhook_configs?: ConfigWebhookConfigDTO[];
/**
* @type array
*/
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AuthtypesAttributeMappingDTO {
/**
* @type string
@@ -2959,85 +3063,6 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface ConfigReceiverDTO {
/**
* @type array
*/
discord_configs?: ConfigDiscordConfigDTO[];
/**
* @type array
*/
email_configs?: ConfigEmailConfigDTO[];
/**
* @type array
*/
incidentio_configs?: ConfigIncidentioConfigDTO[];
/**
* @type array
*/
jira_configs?: ConfigJiraConfigDTO[];
/**
* @type array
*/
mattermost_configs?: ConfigMattermostConfigDTO[];
/**
* @type array
*/
msteams_configs?: ConfigMSTeamsConfigDTO[];
/**
* @type array
*/
msteamsv2_configs?: ConfigMSTeamsV2ConfigDTO[];
/**
* @type string
*/
name?: string;
/**
* @type array
*/
opsgenie_configs?: ConfigOpsGenieConfigDTO[];
/**
* @type array
*/
pagerduty_configs?: ConfigPagerdutyConfigDTO[];
/**
* @type array
*/
pushover_configs?: ConfigPushoverConfigDTO[];
/**
* @type array
*/
rocketchat_configs?: ConfigRocketchatConfigDTO[];
/**
* @type array
*/
slack_configs?: ConfigSlackConfigDTO[];
/**
* @type array
*/
sns_configs?: ConfigSNSConfigDTO[];
/**
* @type array
*/
telegram_configs?: ConfigTelegramConfigDTO[];
/**
* @type array
*/
victorops_configs?: ConfigVictorOpsConfigDTO[];
/**
* @type array
*/
webex_configs?: ConfigWebexConfigDTO[];
/**
* @type array
*/
webhook_configs?: ConfigWebhookConfigDTO[];
/**
* @type array
*/
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**

View File

@@ -192,7 +192,7 @@ function FieldsSelector({
() =>
fields.map((f) => ({
...f,
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
key: f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
})),
[fields],
);

View File

@@ -52,14 +52,20 @@ function OtherFields({
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
(attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
key: buildCompositeKey(
attr.name,
attr.fieldContext as string,
attr.fieldDataType as string | undefined,
),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
}),
);
const addedIds = new Set(
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
addedFields.map(
(f) => f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
),
);
return normalizedSuggestions.filter(
(attr) => !addedIds.has(attr.key as string),

View File

@@ -0,0 +1,58 @@
import { useMemo, type ReactElement } from 'react';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import type { TelemetryFieldKey } from 'types/api/v5/queryRange';
type UseTracesTableColumnsProps<TRow> = {
/** Pinned / always-on columns owned by the consumer (e.g. timestamp for List view, the 5 static columns for Traces grouped view). */
baseColumns: TableColumnDef<TRow>[];
/** Dynamic columns sourced from `selectColumns` (List view). Omit or pass [] for views without a picker (Traces grouped). */
fields?: TelemetryFieldKey[];
};
/**
* Shared column builder for the trace list view and the trace (group-by-trace) view.
*
* Composition: `[...baseColumns, ...fields.map(makeUserFieldCol)]`. Each view owns its
* `baseColumns` inline so view-specific changes (timestamp formatting on list, static-column
* cell renderers on grouped) stay localized. The shared piece is `makeUserFieldCol` — the
* dynamic-field factory that consumes `selectColumns` for the list view.
*/
export function useTracesTableColumns<TRow>({
baseColumns,
fields = [],
}: UseTracesTableColumnsProps<TRow>): TableColumnDef<TRow>[] {
return useMemo<TableColumnDef<TRow>[]>(
() => [...baseColumns, ...fields.map((f) => makeUserFieldCol<TRow>(f))],
[baseColumns, fields],
);
}
function makeUserFieldCol<TRow>(f: TelemetryFieldKey): TableColumnDef<TRow> {
const col: TableColumnDef<Record<string, unknown>> = {
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
header: f.name,
accessorFn: (row): unknown => row[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{stringifyCellValue(value)}</TanStackTable.Text>
),
};
return col as TableColumnDef<TRow>;
}
function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -11,6 +11,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -225,7 +225,7 @@ function BodyTitleRenderer({
<DropdownMenuTrigger asChild>
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent align="start">
<div data-log-detail-ignore="true">
{menuItems.map((item) => (
<DropdownMenuItem

View File

@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
const seen = new Set<string>();
let hasDuplicate = false;
const deduped = columns.filter((c) => {
const key = buildCompositeKey(c.name, c.fieldContext);
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
if (seen.has(key)) {
hasDuplicate = true;
return false;

View File

@@ -281,7 +281,8 @@ const useOptionsMenu = ({
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = preferences?.columns?.filter(
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
(f) =>
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
@@ -364,7 +365,10 @@ const useOptionsMenu = ({
(orderedIds: string[]): void => {
const current = preferences?.columns ?? [];
const byCompositeKey = new Map(
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
current.map((f) => [
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
f,
]),
);
const reordered = orderedIds
.map((id) => byCompositeKey.get(id))

View File

@@ -15,8 +15,15 @@ export const getOptionsFromKeys = (
);
};
// Composite identity for a column. Disambiguates same-name fields across
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
// Falls back to bare name when context is missing.
export const buildCompositeKey = (name: string, context?: string): string =>
context ? `${context}.${name}` : name;
// Composite column id. Disambiguates same-name fields by `context` and `dataType`
// (e.g. attribute.http.status_code ships as both number and string). Each arg
// is appended only when truthy. `dataType` is optional — logs callers stay on
// the 2-arg form until parity lands.
export const buildCompositeKey = (
name: string,
context?: string,
dataType?: string,
): string => {
const withContext = context ? `${context}.${name}` : name;
return dataType ? `${withContext}.${dataType}` : withContext;
};

View File

@@ -2,62 +2,37 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
if (!config?.fieldsSelector) {
return null;
}
return (
<div className={styles.container}>
{config?.fieldsSelector && (
<>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
)}
<Controls
isLoading={isLoading}
totalCount={totalCount}
offset={pagination.offset}
countPerPage={pagination.limit}
perPageOptions={perPageOptions}
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</div>
);
@@ -67,16 +42,8 @@ TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
type TraceExplorerControlsProps = {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

@@ -1,12 +1,3 @@
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -54,18 +54,17 @@ const renderListView = (
);
};
// Helper to verify all controls are visible
// Helper to verify all controls are visible.
// Pagination controls were removed in the TanStack-table migration (infinite
// scroll replaces page-by-page navigation), so only the order-by combobox +
// options trigger remain in the top toolbar.
const verifyControlsVisibility = (): void => {
// Order by controls
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
// Pagination controls
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
// At least one combobox (order-by); page-size selector is gone post-migration.
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
// Options menu (settings button) - check for translation key or actual text
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
@@ -152,15 +151,10 @@ describe('Traces ListView - Error and Empty States', () => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
});
// Order by controls should be interactive
// Order-by combobox should be interactive (pagination buttons removed
// after the TanStack migration switched List view to infinite scroll).
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Pagination controls should be present
const previousButton = screen.getByRole('button', { name: /previous/i });
const nextButton = screen.getByRole('button', { name: /next/i });
expect(previousButton).toBeInTheDocument();
expect(nextButton).toBeInTheDocument();
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
@@ -175,9 +169,9 @@ describe('Traces ListView - Error and Empty States', () => {
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
});
// All controls should be interactive
// At least the order-by combobox should be interactive.
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);

View File

@@ -10,14 +10,16 @@ import {
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { ArrowUp10, Minus } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ResizeTable } from 'components/ResizeTable';
import TanStackTable from 'components/TanStackTableView';
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
@@ -28,24 +30,28 @@ import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
import { Container, tableStyles } from './styles';
import { getListColumns, transformDataWithDate } from './utils';
import { Container } from './styles';
import {
getTraceLink,
makeListFieldCol,
makeTimestampCol,
SpanRow,
transformSpanRows,
} from './utils';
import './ListView.styles.scss';
const PAGE_SIZE = 50;
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
@@ -59,6 +65,7 @@ function ListView({
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const history = useHistory();
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
@@ -77,25 +84,22 @@ function ListView({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
// Infinite-scroll state — owned by this view.
const [pagination, setPagination] = useState<{
offset: number;
limit: number;
}>({
offset: 0,
limit: PAGE_SIZE,
});
const [accumulatedRows, setAccumulatedRows] = useState<SpanRow[]>([]);
const [hasMore, setHasMore] = useState(true);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// TEMP — remove after traces moves to TanStack table.
// Stable sorted-name signature for the queryKey + reset trigger.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Trace API fetches only listed columns → add/remove must refetch from scratch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
@@ -106,6 +110,25 @@ function ListView({
[options?.selectColumns],
);
// Reset accumulator + offset whenever the underlying query identity changes.
useEffect(() => {
setPagination({ offset: 0, limit: PAGE_SIZE });
setAccumulatedRows([]);
setHasMore(true);
}, [
stagedQuery?.id,
globalSelectedTime,
maxTime,
minTime,
orderBy,
selectColumnsSignature,
]);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -114,18 +137,18 @@ function ListView({
minTime,
stagedQuery,
panelType,
paginationConfig,
pagination,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
stagedQuery,
panelType,
pagination,
selectColumnsSignature,
orderBy,
],
);
@@ -144,16 +167,14 @@ function ListView({
dataSource: 'traces',
},
tableParams: {
pagination: paginationConfig,
pagination,
selectColumns: options?.selectColumns,
},
},
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
@@ -168,6 +189,19 @@ function ListView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
// Append fetched page to accumulator (replace when offset === 0).
const responseResult = data?.payload?.data?.newResult?.data?.result;
useEffect(() => {
if (!responseResult) {
return;
}
const newRows = transformSpanRows(responseResult);
setAccumulatedRows((prev) =>
pagination.offset === 0 ? newRows : [...prev, ...newRows],
);
setHasMore(newRows.length >= pagination.limit);
}, [responseResult, pagination.offset, pagination.limit]);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
@@ -176,68 +210,50 @@ function ListView({
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const dataLength =
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
void logEvent('Traces Explorer: Data present', { panelType });
}
}, [isLoading, isFetching, isError, accumulatedRows.length, panelType]);
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
const queryTableData = useMemo(
() => queryTableDataResult || [],
[queryTableDataResult],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = useMemo(
() =>
getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
),
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],
[queryTableData],
);
const handleDragColumn = useCallback(
(fromIndex: number, toIndex: number): void => {
const reordered = [...columns];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
const orderedIds = reordered
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
.filter(Boolean);
config?.addColumn?.onReorder(orderedIds);
},
[columns, config],
);
const handleEndReached = useCallback(() => {
if (!hasMore) {
return;
}
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
}, [hasMore]);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const isDataAbsent =
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length === 0;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const baseColumns = useMemo(
() => [
makeTimestampCol(formatTimezoneAdjustedTimestamp),
...(options?.selectColumns ?? []).map(makeListFieldCol),
],
[formatTimezoneAdjustedTimestamp, options?.selectColumns],
);
const tableColumns = useTracesTableColumns<SpanRow>({ baseColumns });
const handleRowClick = useCallback(
(row: SpanRow): void => {
history.push(getTraceLink(row));
},
[history],
);
const handleRowClickNewTab = useCallback((row: SpanRow): void => {
window.open(
getAbsoluteUrl(getTraceLink(row)),
'_blank',
'noopener,noreferrer',
);
}, []);
useEffect(() => {
if (
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length !== 0
) {
logEvent('Traces Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
return (
<Container>
<div className="trace-explorer-controls">
@@ -258,39 +274,54 @@ function ListView({
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={totalCount}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
<TraceExplorerControls config={config} />
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
{(isLoading || isFetching) && accumulatedRows.length === 0 && (
<TracesLoading />
)}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{!isLoading &&
!isFetching &&
!isError &&
!isFilterApplied &&
accumulatedRows.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isLoading &&
!isFetching &&
accumulatedRows.length === 0 &&
!isError &&
isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isError && transformedQueryTableData.length !== 0 && (
<ResizeTable
tableLayout="fixed"
pagination={false}
scroll={{ x: 'max-content' }}
loading={isFetching}
style={tableStyles}
dataSource={transformedQueryTableData}
columns={columns}
onDragColumn={handleDragColumn}
/>
{accumulatedRows.length !== 0 && (
<div
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<TanStackTable<SpanRow>
data={accumulatedRows}
columns={tableColumns}
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
respectColumnOrder={false}
cellTypographySize="medium"
isLoading={isLoading || isFetching}
onEndReached={handleEndReached}
onColumnOrderChange={(cols): void =>
config?.addColumn?.onReorder(cols.map((c) => c.id))
}
onColumnRemove={config?.addColumn?.onRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
/>
</div>
)}
</Container>
);

View File

@@ -1,7 +1,8 @@
import { CSSProperties } from 'react';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
// Kept for legacy antd consumers (TracesTableComponent, LogsPanelComponent).
// The TanStack ListView doesn't use it.
export const tableStyles: CSSProperties = {
cursor: 'unset',
};
@@ -9,13 +10,30 @@ export const tableStyles: CSSProperties = {
export const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Fallback: the page-level CSS chain (.trace-explorer-page → .trace-explorer →
// .traces-explorer-views) isn't a flex column today, so flex:1 alone has nothing
// to flex against. Anchor a height via the viewport so react-virtuoso (inside
// TanStackTable) has a sized parent to render into.
height: calc(100vh - 240px);
min-height: 400px;
// Match logs explorer table typography (mirrors LogsExplorerList.style.scss).
font-family: 'Space Mono', monospace;
// Row hover affordance — TanStack's row hover reads var(--row-hover-bg) with no
// fallback, so without setting it hover is invisible.
--row-hover-bg: var(--l1-border);
// Small leading gap before the pinned timestamp column. No drag handle here
// (pinned columns aren't movable), so we don't need the full 12px we use in
// the grouped Traces view — 5px just keeps the text off the table edge.
--tanstack-cell-padding-left-first-column: 5px;
// Allow dynamic-field cells to clamp to 3 lines (matches old LineClampedText
// behavior). Header + intrinsic columns stay 1-line by their own settings.
--tanstack-plain-body-line-clamp: 3;
--typography-color: var(--l1-foreground);
`;
export const ErrorText = styled(Typography)`
text-align: center;
`;
export const DateText = styled(Typography)`
min-width: 145px;
`;

View File

@@ -3,6 +3,8 @@ import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
@@ -14,6 +16,14 @@ import LineClampedText from 'periscope/components/LineClampedText/LineClampedTex
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
// `BlockLink`, `getListColumns`, `transformDataWithDate` are kept for legacy
// antd consumers. `getTraceLink` is shared with the TanStack ListView, which
// otherwise uses `make*Col` / `SpanRow` / `transformSpanRows`.
// ---------------------------------------------------------------------------
// Legacy antd consumers
// ---------------------------------------------------------------------------
export function BlockLink({
children,
to,
@@ -41,12 +51,43 @@ export const transformDataWithDate = (
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: RowData): string =>
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
spanId: record.spanID || record.span_id,
/**
* Reads camelCase OR snake_case at runtime — both legacy `RowData` and the new
* `SpanRow` (each used by different ListView/utils consumers) satisfy
* `Record<string, unknown>` because their named props are subtypes of `unknown`.
*/
export const getTraceLink = (record: Record<string, unknown>): string => {
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
function readId(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
return '';
}
function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
@@ -136,3 +177,107 @@ export const getListColumns = (
return [...initialColumns, ...columns];
};
// ---------------------------------------------------------------------------
// TanStack ListView (current)
// ---------------------------------------------------------------------------
// Span row shape for the trace list view. Known intrinsic fields explicit; the
// rest of the row comes from user-selected dynamic columns (selectColumns), hence
// the Record intersection. `timestamp` is added by transformSpanRows from the
// API's wrapping ListItem.timestamp (data itself omits it).
export type SpanRow = {
trace_id: string;
span_id: string;
timestamp: string;
} & Record<string, unknown>;
export const transformSpanRows = (data: QueryDataV3[]): SpanRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => ({
...(item.data as Record<string, unknown>),
timestamp: item.timestamp,
})) as unknown as SpanRow[];
};
// Field-name allowlists that drive signal-specific cell rendering (kept from the
// pre-TanStack getListColumns). Both legacy camelCase + snake_case variants are
// listed because the API has shipped both over time.
const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'responseStatusCode',
'response_status_code',
]);
const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
type TimestampFormatter = (
input: TimestampInput,
format?: string,
) => string | number;
export function makeTimestampCol(
formatTimezoneAdjustedTimestamp: TimestampFormatter,
): TableColumnDef<SpanRow> {
return {
id: buildCompositeKey('timestamp', 'span'),
header: 'Timestamp',
accessorFn: (row): unknown => row.timestamp,
// Pinned left as a visual anchor during horizontal scroll. Trade-off: the
// sticky-positioning + cell `overflow: hidden` in TanStackTable.module.scss
// makes the right-edge resize handle effectively unhittable for pinned
// columns — accepted.
pin: 'left',
canBeHidden: false,
enableRemove: false,
width: { default: 170, min: 170 },
cell: ({ value }): JSX.Element => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
},
};
}
export function makeListFieldCol(
f: TelemetryFieldKey,
): TableColumnDef<SpanRow> {
return {
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
header: f.name,
accessorFn: (row): unknown => row[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): JSX.Element => {
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={f.name}>N/A</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (STATUS_FIELD_NAMES.has(f.name)) {
return (
<Badge data-testid={f.name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(f.name)) {
return (
<TanStackTable.Text data-testid={f.name}>
{getMs(text)}
ms
</TanStackTable.Text>
);
}
return <TanStackTable.Text data-testid={f.name}>{text}</TanStackTable.Text>;
},
};
}

View File

@@ -1,50 +1,69 @@
import { generatePath, Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ROUTES from 'constants/routes';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
import { ListItem } from 'types/api/widgets/getQuery';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
export const columns: ColumnsType<ListItem['data']> = [
// Trace-grouped (group-by-trace) row shape. Distinct from logs' `ListItem.data`
// (which is `Omit<ILog, 'timestamp' | 'span_id'>` — the legacy logs shape).
// Trace rows ship trace-summary fields; runtime keys often contain dots (e.g.
// `service.name`), so the row indexes via string keys, not nested-property access.
export type TraceRow = {
'service.name': string;
name: string;
duration_nano: number | string;
span_count: number | string;
trace_id: string;
};
export const columns: TableColumnDef<TraceRow>[] = [
{
title: 'Root Service Name',
dataIndex: 'service.name',
key: 'serviceName',
},
{
title: 'Root Operation Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Root Duration (in ms)',
dataIndex: 'duration_nano',
key: 'durationNano',
render: (duration: number): JSX.Element => (
<Typography>{getMs(String(duration))}ms</Typography>
id: buildCompositeKey('service.name', 'resource'),
header: 'Root Service Name',
accessorFn: (row): unknown => row['service.name'],
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
width: { min: 192 },
},
{
title: 'No of Spans',
dataIndex: 'span_count',
key: 'span_count',
},
{
title: 'TraceID',
dataIndex: 'trace_id',
key: 'traceID',
render: (traceID: string): JSX.Element => (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, {
id: traceID,
})}
data-testid="trace-id"
>
{traceID}
</Link>
id: 'name',
header: 'Root Operation Name',
accessorFn: (row): unknown => row.name,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text data-testid="trace-id">
{String(value ?? '')}
</TanStackTable.Text>
),
width: { min: 200 },
},
{
id: 'duration_nano',
header: 'Root Duration (in ms)',
accessorFn: (row): unknown => row.duration_nano,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{getMs(String(value))}ms</TanStackTable.Text>
),
width: { min: 130 },
},
{
id: 'span_count',
header: 'No of Spans',
accessorFn: (row): unknown => row.span_count,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
width: { min: 100 },
},
{
id: 'trace_id',
header: 'TraceID',
accessorFn: (row): unknown => row.trace_id,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
width: { min: 250 },
},
];

View File

@@ -4,38 +4,44 @@ import {
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import TanStackTable from 'components/TanStackTableView';
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { columns as baseColumns, TraceRow } from './configs';
import { ActionsContainer, Container } from './styles';
const PAGE_SIZE = 50;
interface TracesViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
@@ -49,6 +55,7 @@ function TracesView({
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const history = useHistory();
const { stagedQuery, panelType } = useQueryBuilder();
const {
@@ -57,9 +64,20 @@ function TracesView({
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
// Infinite-scroll state — owned by this view.
const [pagination, setPagination] = useState<Pagination>({
offset: 0,
limit: PAGE_SIZE,
});
const [accumulatedRows, setAccumulatedRows] = useState<TraceRow[]>([]);
const [hasMore, setHasMore] = useState(true);
// Reset accumulator + offset whenever the underlying query identity changes.
useEffect(() => {
setPagination({ offset: 0, limit: PAGE_SIZE });
setAccumulatedRows([]);
setHasMore(true);
}, [stagedQuery?.id, globalSelectedTime, maxTime, minTime]);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
@@ -74,16 +92,9 @@ function TracesView({
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
[
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
pagination,
],
[globalSelectedTime, maxTime, minTime, stagedQuery, panelType, pagination],
);
if (queryKeyRef) {
@@ -100,7 +111,7 @@ function TracesView({
dataSource: 'traces',
},
tableParams: {
pagination: paginationQueryData,
pagination,
},
},
ENTITY_VERSION_V5,
@@ -117,11 +128,20 @@ function TracesView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const tableData = useMemo(
() => responseData?.map((listItem) => listItem.data),
[responseData],
);
// Append fetched page to accumulator (replace when offset === 0).
const responseList = data?.payload?.data?.newResult?.data?.result?.[0]?.list;
useEffect(() => {
if (!responseList) {
return;
}
// API returns trace-summary rows; the `ListItem.data` static type is the
// legacy logs shape, so route through `unknown` to land on `TraceRow`.
const newRows = responseList.map((li) => li.data) as unknown as TraceRow[];
setAccumulatedRows((prev) =>
pagination.offset === 0 ? newRows : [...prev, ...newRows],
);
setHasMore(newRows.length >= pagination.limit);
}, [responseList, pagination.offset, pagination.limit]);
useEffect(() => {
if (isLoading || isFetching) {
@@ -132,16 +152,48 @@ function TracesView({
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
logEvent('Traces Explorer: Data present', {
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, panelType, tableData]);
}, [isLoading, isFetching, isError, accumulatedRows.length]);
const handleEndReached = useCallback(() => {
if (!hasMore) {
return;
}
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
}, [hasMore]);
const tableColumns = useTracesTableColumns<TraceRow>({ baseColumns });
const handleRowClick = useCallback(
(row: TraceRow): void => {
const traceId = String(row.trace_id);
history.push(generatePath(ROUTES.TRACE_DETAIL, { id: traceId }));
},
[history],
);
const handleRowClickNewTab = useCallback((row: TraceRow): void => {
const traceId = String(row.trace_id);
const path = generatePath(ROUTES.TRACE_DETAIL, { id: traceId });
window.open(getAbsoluteUrl(path), '_blank', 'noopener,noreferrer');
}, []);
//oxlint-disable-next-line no-console
console.log('TracesView rendered with rows:', {
accumulatedRows,
tableColumns,
isLoading,
isFetching,
isError,
error,
});
return (
<Container>
{(tableData || []).length !== 0 && (
{accumulatedRows.length !== 0 && (
<ActionsContainer>
<Typography>
This tab only shows Root Spans. More details
@@ -150,20 +202,12 @@ function TracesView({
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</ActionsContainer>
)}
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
{(isLoading || isFetching) && accumulatedRows.length === 0 && (
<TracesLoading />
)}
@@ -171,25 +215,36 @@ function TracesView({
!isFetching &&
!isError &&
!isFilterApplied &&
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
accumulatedRows.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
{!isLoading &&
!isFetching &&
(tableData || []).length === 0 &&
accumulatedRows.length === 0 &&
!isError &&
isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
)}
{(tableData || []).length !== 0 && (
<ResizeTable
loading={isLoading}
columns={columns}
tableLayout="fixed"
dataSource={tableData}
scroll={{ x: true }}
pagination={false}
/>
{accumulatedRows.length !== 0 && (
<div
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<TanStackTable<TraceRow>
data={accumulatedRows}
columns={tableColumns}
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
cellTypographySize="medium"
isLoading={isLoading || isFetching}
onEndReached={handleEndReached}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
/>
</div>
)}
</Container>
);

View File

@@ -3,6 +3,16 @@ import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: calc(100vh - 240px);
min-height: 400px;
// Row hover affordance
--row-hover-bg: var(--l1-border);
// Breathing room before the first column so cell content doesn't hug the corner.
--tanstack-cell-padding-left-first-column: 12px;
`;
export const ActionsContainer = styled.div`

View File

@@ -1,29 +1,36 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
ClipboardCopy,
Configure,
Ellipsis,
FileJson,
Fullscreen,
LockKeyhole,
PenLine,
Plus,
Trash2,
} from '@signozhq/icons';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import ROUTES from 'constants/routes';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import SettingsDrawer from '../SettingsDrawer';
import styles from '../DashboardDescription.module.scss';
interface Props {
interface DashboardActionsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
@@ -45,17 +52,19 @@ function DashboardActions({
onAddPanel,
onLockToggle,
onOpenRename,
}: Props): JSX.Element {
}: DashboardActionsProps): JSX.Element {
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id;
const id = dashboard.id ?? '';
const title = dashboard.spec?.display?.name ?? '';
const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] =
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(id);
useEffect(() => {
if (state.error) {
@@ -66,9 +75,12 @@ function DashboardActions({
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2);
const dashboardDataJSON = useCallback(
(): string => JSON.stringify(dashboard, null, 2),
[dashboard],
);
const exportJSON = (): void => {
const exportJSON = useCallback((): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -78,119 +90,141 @@ function DashboardActions({
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
}, [dashboardDataJSON, title]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
setIsDeleteOpen(false);
history.replace(ROUTES.ALL_DASHBOARD);
},
});
}, [deleteDashboardMutation]);
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
if (!isDashboardLocked && editDashboard) {
editGroup.push({
key: 'rename',
label: 'Rename',
icon: <PenLine size={14} />,
onClick: onOpenRename,
});
}
if (isAuthor || user.role === USER_ROLES.ADMIN) {
editGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
icon: <LockKeyhole size={14} />,
disabled: dashboard.createdBy === 'integration',
onClick: onLockToggle,
});
}
editGroup.push({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handle.enter,
});
const exportGroup: MenuItem[] = [
{
key: 'export',
label: 'Export JSON',
icon: <FileJson size={14} />,
onClick: exportJSON,
},
{
key: 'copy',
label: 'Copy as JSON',
icon: <ClipboardCopy size={14} />,
onClick: (): void => setCopy(dashboardDataJSON()),
},
];
const dangerGroup: MenuItem[] = [
{
key: 'delete',
label: 'Delete dashboard',
icon: <Trash2 size={14} />,
danger: true,
onClick: (): void => setIsDeleteOpen(true),
},
];
return [editGroup, exportGroup, dangerGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
);
}, [
isDashboardLocked,
editDashboard,
isAuthor,
user.role,
dashboard.createdBy,
onOpenRename,
onLockToggle,
handle.enter,
exportJSON,
setCopy,
dashboardDataJSON,
]);
return (
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashboardSettingsOpen(visible)}
rootClassName={styles.dashboardSettings}
content={
<div className={styles.menuContent}>
<section className={styles.section1}>
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<TooltipSimple
title={
dashboard.createdBy === 'integration'
? 'Dashboards created by integrations cannot be unlocked'
: ''
}
>
<Button
variant="ghost"
prefix={<LockKeyhole size={14} />}
disabled={dashboard.createdBy === 'integration'}
onClick={(): void => {
setIsDashboardSettingsOpen(false);
onLockToggle();
}}
testId="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</TooltipSimple>
)}
{!isDashboardLocked && editDashboard && (
<Button
variant="ghost"
prefix={<PenLine size={14} />}
onClick={(): void => {
onOpenRename();
setIsDashboardSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
variant="ghost"
prefix={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className={styles.section2}>
<Button
variant="ghost"
prefix={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashboardSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
variant="ghost"
prefix={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashboardSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className={styles.deleteDashboard}>
<DeleteButton
createdBy={dashboard.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</Popover>
</DropdownMenuSimple>
{!isDashboardLocked && editDashboard && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
className={styles.addPanelBtn}
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard "${title}"?`}
description="This action cannot be undone."
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
</div>
);
}

View File

@@ -20,6 +20,7 @@
align-items: center;
gap: 8px;
width: 45%;
height: 40px;
.dashboardImg {
height: 16px;
@@ -42,6 +43,35 @@
overflow: hidden;
}
.clickableTitle {
cursor: pointer;
}
.titleEdit {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.titleInput {
flex: 1;
min-width: 0;
max-width: 70%;
}
.titleEditActionButton {
--button-height: auto;
--button-padding: 4px;
flex-shrink: 0;
}
.titleSaveActionButton {
--button-border-color: var(--text-forest-700);
--button-outlined-foreground: var(--text-forest-700);
}
.publicDashboardIcon {
margin-right: 4px;
}
@@ -54,6 +84,7 @@
flex-wrap: wrap;
align-items: center;
gap: 14px;
height: 40px;
.icons {
display: flex;
@@ -77,41 +108,6 @@
.icons:hover {
background-color: unset;
}
.configureButton {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.addPanelBtn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
@@ -209,95 +205,6 @@
}
}
.renameDashboard {
:global(.ant-modal-content) {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
:global(.ant-modal-header) {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
:global(.ant-modal-title) {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
:global(.ant-modal-body) {
padding: 16px;
.dashboardContent {
display: flex;
flex-direction: column;
gap: 8px;
.nameText {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboardNameInput {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
:global(.ant-modal-footer) {
padding: 16px;
margin-top: 0px;
.dashboardRename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancelBtn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
}
.renameBtn {
display: flex;
align-items: center;
width: 169px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
}
}
}
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -3,12 +3,12 @@ import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface Props {
interface DashboardMetaProps {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: Props): JSX.Element {
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
return (
<>
{tags.length > 0 && (

View File

@@ -1,14 +1,25 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from '../DashboardDescription.module.scss';
interface Props {
interface DashboardTitleProps {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditable: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardTitle({
@@ -16,18 +27,76 @@ function DashboardTitle({
image,
isPublicDashboard,
isDashboardLocked,
}: Props): JSX.Element {
isEditable,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardTitleProps): JSX.Element {
const canEdit = isEditable && !isDashboardLocked;
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={styles.dashboardTitle}
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</TooltipSimple>
{isEditing ? (
<div className={styles.titleEdit}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.titleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
size="icon"
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="destructive"
size="icon"
className={styles.titleEditActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.clickableTitle]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">

View File

@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
interface UseEditableTitleArgs {
value: string;
onSave: (next: string) => void;
}
interface UseEditableTitleResult {
isEditing: boolean;
draft: string;
setDraft: (next: string) => void;
startEdit: () => void;
cancel: () => void;
commit: () => void;
}
/**
* Drives an inline-editable title. The parent owns the canonical `value`; this
* hook tracks the in-flight `draft` and whether we're editing. `commit` saves
* only when the trimmed draft is non-empty and actually changed. A `cancelled`
* ref guards against a blur firing right after Escape from also committing.
*/
export function useEditableTitle({
value,
onSave,
}: UseEditableTitleArgs): UseEditableTitleResult {
const [isEditing, setIsEditing] = useState<boolean>(false);
const [draft, setDraft] = useState<string>(value);
const cancelled = useRef<boolean>(false);
// Keep the draft in sync with the canonical value while not editing (e.g.
// after a refetch updates the title).
useEffect(() => {
if (!isEditing) {
setDraft(value);
}
}, [value, isEditing]);
const startEdit = (): void => {
cancelled.current = false;
setDraft(value);
setIsEditing(true);
};
const cancel = (): void => {
cancelled.current = true;
setIsEditing(false);
};
const commit = (): void => {
if (cancelled.current) {
cancelled.current = false;
return;
}
const trimmed = draft.trim();
if (trimmed && trimmed !== value) {
onSave(trimmed);
}
setIsEditing(false);
};
return { isEditing, draft, setDraft, startEdit, cancel, commit };
}

View File

@@ -1,70 +0,0 @@
import { Input, Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
open: boolean;
value: string;
isLoading: boolean;
onChange: (value: string) => void;
onRename: () => void;
onClose: () => void;
}
function RenameDashboardModal({
open,
value,
isLoading,
onChange,
onRename,
onClose,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Rename Dashboard"
onOk={onRename}
onCancel={onClose}
rootClassName={styles.renameDashboard}
footer={
<div className={styles.dashboardRename}>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
className={styles.renameBtn}
onClick={onRename}
disabled={isLoading}
>
Rename Dashboard
</Button>
<Button
variant="ghost"
prefix={<X size={14} />}
className={styles.cancelBtn}
onClick={onClose}
>
Cancel
</Button>
</div>
}
>
<div className={styles.dashboardContent}>
<Typography.Text className={styles.nameText}>
Enter a new name
</Typography.Text>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
</Modal>
);
}
export default RenameDashboardModal;

View File

@@ -0,0 +1,43 @@
.settingsContainerRoot {
:global(.ant-drawer-wrapper-body) {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
:global(.ant-drawer-header) {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
:global(.ant-drawer-header-title) {
gap: 16px;
:global(.ant-drawer-title) {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
:global(.ant-drawer-close) {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
:global(.ant-drawer-body) {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -0,0 +1,34 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import styles from './SettingsDrawer.module.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName={styles.settingsContainerRoot}
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
@@ -15,6 +15,7 @@ import type {
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -22,7 +23,7 @@ import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal';
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
import styles from './DashboardDescription.module.scss';
@@ -52,6 +53,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -59,16 +63,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
setUpdatedTitle(title);
}, [title]);
const handleLockDashboardToggle = async (): Promise<void> => {
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
@@ -84,41 +79,43 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
} catch (error) {
showErrorModal(error as APIError);
}
};
}, [id, isDashboardLocked, refetch, showErrorModal]);
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
refetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onNameSave = useCallback(
async (next: string): Promise<void> => {
if (!id) {
return;
}
try {
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: next,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[id, refetch, showErrorModal],
);
const onEmptyWidgetHandler = (): void => {
const { isEditing, draft, setDraft, startEdit, cancel, commit } =
useEditableTitle({
value: title,
onSave: onNameSave,
});
const onEmptyWidgetHandler = useCallback((): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
setIsPanelTypeSelectionModalOpen(true);
}, [id, setIsPanelTypeSelectionModalOpen]);
return (
<Card className={styles.dashboardDescriptionContainer}>
@@ -129,6 +126,13 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
image={image}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
isEditable={editDashboard}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
<DashboardActions
dashboard={dashboard}
@@ -139,19 +143,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={(): void => setIsRenameDashboardOpen(true)}
onOpenRename={startEdit}
/>
</section>
<DashboardMeta tags={tags} description={description} />
<RenameDashboardModal
open={isRenameDashboardOpen}
value={updatedTitle}
isLoading={isRenameLoading}
onChange={setUpdatedTitle}
onRename={onNameChangeHandler}
onClose={(): void => setIsRenameDashboardOpen(false)}
/>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
.placeholder {
padding: 24px;
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
padding-top: 4px;
}

View File

@@ -0,0 +1,114 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Radio, Tooltip } from 'antd';
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { getAbsoluteUrl } from 'utils/basePath';
import cx from 'classnames';
import styles from '../GeneralSettings.module.scss';
interface CrossPanelSyncProps {
dashboardId: string;
}
function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
const [cursorSyncMode, setCursorSyncMode] =
useDashboardCursorSyncMode(dashboardId);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardId);
return (
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
);
}
export default CrossPanelSync;

View File

@@ -0,0 +1,85 @@
import { Dispatch, SetStateAction } from 'react';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
import { Col, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import styles from '../GeneralSettings.module.scss';
const { Option } = Select;
interface GeneralFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function GeneralForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: GeneralFormProps): JSX.Element {
return (
<Col className={styles.overviewSettings}>
<Space direction="vertical" className={styles.formSpace}>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={image}
onChange={onImageChange}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</Space>
</Col>
);
}
export default GeneralForm;

View File

@@ -0,0 +1,238 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px 16px;
}
.overviewSettings {
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.formSpace {
width: 100%;
display: flex;
flex-direction: column;
gap: 21px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
display: flex;
align-items: center;
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../GeneralSettings.module.scss';
interface UnsavedChangesFooterProps {
count: number;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function UnsavedChangesFooter({
count,
isSaving,
onDiscard,
onSave,
}: UnsavedChangesFooterProps): JSX.Element {
const { t } = useTranslation('common');
return (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{count} unsaved change
{count > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
variant="ghost"
disabled={isSaving}
prefix={<X size={14} />}
onClick={onDiscard}
className={styles.discardBtn}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
disabled={isSaving}
loading={isSaving}
prefix={<Check size={14} />}
testId="save-dashboard-config"
onClick={onSave}
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
);
}
export default UnsavedChangesFooter;

View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { isEqual } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import GeneralForm from './GeneralForm/GeneralForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
import styles from './GeneralSettings.module.scss';
interface GeneralSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
const id = dashboard.id;
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard.tags ?? []),
[dashboard.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
const [updatedDescription, setUpdatedDescription] =
useState<string>(description);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
const { showErrorModal } = useErrorModal();
// Sync state when dashboard refetches after a save
useEffect(() => {
setUpdatedTitle(title);
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
}
return ops;
}, [
updatedTitle,
title,
updatedDescription,
description,
updatedImage,
image,
updatedTags,
tagsAsStrings,
]);
const onSaveHandler = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
n += 1;
}
});
setNumberOfUnsavedChanges(n);
}, [
description,
image,
tagsAsStrings,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = useCallback((): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDescription(description);
}, [title, image, tagsAsStrings, description]);
return (
<div className={styles.overviewContent}>
<GeneralForm
title={updatedTitle}
description={updatedDescription}
image={updatedImage}
tags={updatedTags}
onTitleChange={setUpdatedTitle}
onDescriptionChange={setUpdatedDescription}
onImageChange={setUpdatedImage}
onTagsChange={setUpdatedTags}
/>
<CrossPanelSync dashboardId={id} />
{numberOfUnsavedChanges > 0 && (
<UnsavedChangesFooter
count={numberOfUnsavedChanges}
isSaving={isSaving}
onDiscard={discardHandler}
onSave={onSaveHandler}
/>
)}
</div>
);
}
export default GeneralSettings;

View File

@@ -0,0 +1,24 @@
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
return tagStrings
.map((s) => {
const trimmed = s.trim();
const idx = trimmed.indexOf(':');
if (idx === -1) {
return { key: trimmed, value: trimmed };
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
return { key, value: value || key };
})
.filter((t) => t.key.length > 0);
}

View File

@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import styles from './DashboardSettings.module.scss';
interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
}
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items = useMemo(
() => [
{
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
},
{
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
},
],
[dashboard],
);
return <Tabs defaultValue="general" items={items} />;
}
export default DashboardSettings;

View File

@@ -0,0 +1,23 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './DashboardSettings.module.scss';
/**
* TEMPORARY: stand-in for the not-yet-built Variables / Publish settings tabs.
* Will be cleaned up later once those tabs ship their real content.
*/
export function SettingsTabPlaceholder({
message,
}: {
message: string;
}): JSX.Element {
return (
<div className={styles.placeholder}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}

View File

@@ -0,0 +1,86 @@
.emptyState {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 48px 16px;
}
.content {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 480px;
}
.heading {
display: flex;
flex-direction: column;
gap: 6px;
.emoji {
height: 32px;
width: 32px;
}
.welcome {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: -0.08px;
}
.welcomeInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
}
.addPanel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l1-border);
border-radius: 6px;
}
.addPanelText {
display: flex;
align-items: flex-start;
gap: 10px;
.icon {
height: 14px;
width: 14px;
margin-top: 2px;
}
}
.addPanelCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.addPanelTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.addPanelInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}

View File

@@ -0,0 +1,63 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import styles from './DashboardEmptyState.module.scss';
interface DashboardEmptyStateProps {
canAddPanel: boolean;
}
function DashboardEmptyState({
canAddPanel,
}: DashboardEmptyStateProps): JSX.Element {
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
return (
<section className={styles.emptyState}>
<div className={styles.content}>
<div className={styles.heading}>
<img src={dashboardEmojiUrl} alt="" className={styles.emoji} />
<Typography.Text className={styles.welcome}>
Welcome to your new dashboard
</Typography.Text>
<Typography.Text className={styles.welcomeInfo}>
Follow the steps to populate it with data and share with your teammates
</Typography.Text>
</div>
<div className={styles.addPanel}>
<div className={styles.addPanelText}>
<img src={landscapeUrl} alt="" className={styles.icon} />
<div className={styles.addPanelCopy}>
<Typography.Text className={styles.addPanelTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.addPanelInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
</div>
{canAddPanel && (
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
testId="add-panel"
>
New Panel
</Button>
)}
</div>
</div>
</section>
);
}
export default DashboardEmptyState;

View File

@@ -4,7 +4,7 @@
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border: 1px solid var(--l1-border);
border-radius: 4px;
overflow: hidden;
}
@@ -14,7 +14,7 @@
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
border-bottom: 1px solid var(--l1-border);
cursor: grab;
}
@@ -42,7 +42,7 @@
align-items: center;
justify-content: center;
padding: 12px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
font-size: 12px;
text-align: center;
}

View File

@@ -12,7 +12,15 @@ import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface Props {
/** Panel action context — present together only in editable sectioned mode. */
export interface PanelActionsConfig {
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
@@ -21,22 +29,16 @@ interface Props {
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
function Panel({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
panelActions,
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
@@ -65,13 +67,13 @@ function Panel({
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />

View File

@@ -6,11 +6,11 @@
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
color: var(--l1-foreground);
background: var(--l2-background);
}
}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
@@ -8,7 +9,7 @@ import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
interface Props {
interface PanelActionsMenuProps {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
@@ -22,7 +23,7 @@ function PanelActionsMenu({
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
}: PanelActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
@@ -75,8 +76,11 @@ function PanelActionsMenu({
return (
<DropdownMenuSimple menu={{ items }}>
<button
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
@@ -87,7 +91,7 @@ function PanelActionsMenu({
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</Button>
</DropdownMenuSimple>
);
}

View File

@@ -10,9 +10,9 @@
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border: 1px solid var(--l1-border);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
color: var(--l1-foreground);
cursor: pointer;
text-align: left;

View File

@@ -1,48 +1,10 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { PANEL_TYPES } from './constants';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
@@ -52,7 +14,7 @@ function PanelTypeSelectionModal({
open,
onClose,
onSelect,
}: Props): JSX.Element {
}: PanelTypeSelectionModalProps): JSX.Element {
return (
<Modal
open={open}
@@ -63,16 +25,17 @@ function PanelTypeSelectionModal({
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
<Button
key={type.pluginKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
</Button>
))}
</div>
</Modal>

View File

@@ -0,0 +1,36 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];

View File

@@ -0,0 +1,5 @@
export interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}

View File

@@ -36,9 +36,6 @@ export function useAddPanelToSection({
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;

View File

@@ -5,13 +5,13 @@
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
color: var(--l1-foreground);
}
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../../utils';
@@ -10,7 +11,7 @@ import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
interface AddSectionControlProps {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
isSectioned: boolean;
@@ -20,7 +21,7 @@ function AddSectionControl({
sections,
layouts,
isSectioned,
}: Props): JSX.Element {
}: AddSectionControlProps): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts });
const { migrate, isSaving } = useFirstSectionMigration({ sections });
@@ -30,30 +31,31 @@ function AddSectionControl({
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
const handleClick = useCallback((): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
}, [needsMigration, addSection]);
const handleConfirmMigration = async (): Promise<void> => {
const handleConfirmMigration = useCallback(async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
}, [migrate]);
return (
<>
<button
<Button
type="button"
variant="ghost"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
</Button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}

View File

@@ -1,7 +1,7 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
interface FirstSectionMigrationModalProps {
open: boolean;
isSaving: boolean;
onClose: () => void;
@@ -18,7 +18,7 @@ function FirstSectionMigrationModal({
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
}: FirstSectionMigrationModalProps): JSX.Element {
return (
<Modal
open={open}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
interface RenameSectionModalProps {
open: boolean;
initialValue: string;
isSaving: boolean;
@@ -16,7 +16,7 @@ function RenameSectionModal({
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
}: RenameSectionModalProps): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.

View File

@@ -1,9 +1,20 @@
.section {
margin-bottom: 12px;
border: 1px solid var(--bg-slate-500);
border: 1px solid var(--l1-border);
border-radius: 4px;
}
.dragging {
opacity: 0.8;
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}
.emptySection {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 12px;
}

View File

@@ -1,8 +1,11 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
@@ -19,7 +22,7 @@ import SectionHeader, {
} from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
interface SectionProps {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
@@ -38,8 +41,12 @@ function Section({
onMovePanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
}: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
@@ -54,30 +61,30 @@ function Section({
layoutIndex: section.layoutIndex,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const handleRenameSubmit = useCallback(
async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
},
[rename],
);
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const handleSelectPanelType = useCallback(
(pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
},
[onAddPanel, section.layoutIndex],
);
const { deleteSection } = useDeleteSection({ section });
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const handleDeleteSection = useCallback((): void => {
void deleteSection();
setIsDeleteOpen(false);
}, [deleteSection]);
const grid = (
<SectionGrid
@@ -118,13 +125,35 @@ function Section({
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
actions={
isEditable
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => setIsAddingPanel(true),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
{open &&
(section.items.length > 0 ? (
grid
) : (
<div className={styles.emptySection}>
{isEditable && (
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
)}
</div>
))}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
@@ -137,6 +166,13 @@ function Section({
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete section "${section.title ?? ''}"?`}
description="Panels in this section will be removed."
onConfirm={handleDeleteSection}
onClose={(): void => setIsDeleteOpen(false)}
/>
</div>
);
}

View File

@@ -6,11 +6,11 @@
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
color: var(--l1-foreground);
background: var(--l2-background);
}
}

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import styles from './SectionActionsMenu.module.scss';
interface Props {
interface SectionActionsMenuProps {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
@@ -17,7 +18,7 @@ function SectionActionsMenu({
onAddPanel,
onRename,
onDeleteSection,
}: Props): JSX.Element {
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onAddPanel) {
@@ -53,14 +54,17 @@ function SectionActionsMenu({
return (
<DropdownMenuSimple menu={{ items }}>
<button
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Section actions"
data-testid={`dashboard-section-actions-${sectionId}`}
>
<EllipsisVertical size={14} />
</button>
</Button>
</DropdownMenuSimple>
);
}

View File

@@ -2,7 +2,7 @@ import type { DashboardSection } from '../../../utils';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './SectionDragPreview.module.scss';
interface Props {
interface SectionDragPreviewProps {
section: DashboardSection;
}
@@ -11,7 +11,7 @@ interface Props {
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
* cheap and never triggers RGL width re-measurement.
*/
function SectionDragPreview({ section }: Props): JSX.Element {
function SectionDragPreview({ section }: SectionDragPreviewProps): JSX.Element {
const panelCount = section.items.length;
const title = `${section.title ?? ''} · ${panelCount} ${
panelCount === 1 ? 'panel' : 'panels'

View File

@@ -11,7 +11,7 @@ import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
interface SectionGridProps {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
@@ -29,7 +29,7 @@ function SectionGrid({
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
@@ -66,10 +66,16 @@ function SectionGrid({
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
currentLayoutIndex={layoutIndex}
sections={isEditable ? sections : undefined}
onMovePanel={isEditable ? onMovePanel : undefined}
onDeletePanel={isEditable ? onDeletePanel : undefined}
panelActions={
isEditable && onMovePanel && onDeletePanel
? {
currentLayoutIndex: layoutIndex,
sections: sections ?? [],
onMovePanel,
onDeletePanel,
}
: undefined
}
/>
</div>
))}

View File

@@ -5,7 +5,7 @@
padding: 8px 12px;
&.headerOpen {
border-bottom: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--l1-border);
}
}
@@ -16,7 +16,7 @@
padding: 0;
background: transparent;
border: none;
color: var(--bg-vanilla-400, #8993ae);
color: var(--l2-foreground);
cursor: grab;
&:active {
@@ -33,7 +33,8 @@
padding: 0;
background: transparent;
border: none;
color: inherit;
// Muted chevron; the title below carries the prominent heading color.
color: var(--l2-foreground);
text-align: left;
cursor: pointer;
min-width: 0;
@@ -41,6 +42,8 @@
.title {
margin-left: 4px;
color: var(--l1-foreground);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,6 +1,7 @@
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
@@ -13,7 +14,14 @@ export interface SectionDragHandle {
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
interface Props {
/** Editable-mode section actions — present together or not at all. */
export interface SectionHeaderActions {
onRename: () => void;
onAddPanel: () => void;
onDeleteSection: () => void;
}
interface SectionHeaderProps {
sectionId: string;
title: string;
open: boolean;
@@ -21,9 +29,8 @@ interface Props {
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
onRename?: () => void;
onAddPanel?: () => void;
onDeleteSection?: () => void;
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
actions?: SectionHeaderActions;
}
function SectionHeader({
@@ -33,16 +40,16 @@ function SectionHeader({
onToggle,
repeatVariable,
dragHandle,
onRename,
onAddPanel,
onDeleteSection,
}: Props): JSX.Element {
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
actions,
}: SectionHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
{dragHandle ? (
<button
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.dragHandle}
ref={dragHandle.setActivatorNodeRef}
aria-label="Drag to reorder section"
@@ -51,10 +58,12 @@ function SectionHeader({
{...dragHandle.listeners}
>
<GripVertical size={14} />
</button>
</Button>
) : null}
<button
<Button
type="button"
variant="ghost"
color="secondary"
className={styles.toggle}
onClick={onToggle}
data-testid={`dashboard-section-toggle-${sectionId}`}
@@ -66,13 +75,13 @@ function SectionHeader({
(repeats per ${repeatVariable})
</Typography.Text>
) : null}
</button>
{hasActions ? (
</Button>
{actions ? (
<SectionActionsMenu
sectionId={sectionId}
onAddPanel={onAddPanel}
onRename={onRename}
onDeleteSection={onDeleteSection}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onDeleteSection={actions.onDeleteSection}
/>
) : null}
</div>

View File

@@ -20,12 +20,12 @@ import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
interface Props {
interface SectionListProps {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
function SectionList({ sections, layouts }: Props): JSX.Element {
function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {

View File

@@ -7,7 +7,7 @@ import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface Props {
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
@@ -21,7 +21,7 @@ function SortableSection({
onAddPanel,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
}: SortableSectionProps): JSX.Element {
const {
attributes,
listeners,

View File

@@ -1,7 +1,5 @@
import { ReactNode, useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
@@ -9,7 +7,7 @@ import type {
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
@@ -17,12 +15,15 @@ import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
interface PanelsAndSectionsLayoutProps {
layouts: DashboardtypesLayoutDTO[];
panels: Record<string, DashboardtypesPanelDTO | undefined>;
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
function PanelsAndSectionsLayout({
layouts,
panels,
}: PanelsAndSectionsLayoutProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const sections = useMemo(
@@ -40,16 +41,7 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
<div className={styles.emptyState}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
return <DashboardEmptyState canAddPanel={isEditable} />;
}
if (isSectioned) {
@@ -61,18 +53,7 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
));
};
return (
<div className={styles.body}>
{renderContent()}
{isEditable ? (
<AddSectionControl
sections={sections}
layouts={layouts}
isSectioned={isSectioned}
/>
) : null}
</div>
);
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -0,0 +1,12 @@
.body {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,69 @@
import { ReactNode } from 'react';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import styles from './ConfirmDeleteDialog.module.scss';
interface ConfirmDeleteDialogProps {
open: boolean;
title: string;
description: ReactNode;
confirmLabel?: string;
isLoading?: boolean;
onConfirm: () => void;
onClose: () => void;
}
/**
* Shared destructive-confirm dialog built on @signozhq/ui DialogWrapper (not
* antd Modal), so it inherits the design-system styling/theme. Used by the
* dashboard and section delete flows.
*/
function ConfirmDeleteDialog({
open,
title,
description,
confirmLabel = 'Delete',
isLoading = false,
onConfirm,
onClose,
}: ConfirmDeleteDialogProps): JSX.Element {
const footer = (
<div className={styles.footer}>
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isLoading}
onClick={onConfirm}
testId="confirm-delete"
>
<Trash2 size={12} />
{confirmLabel}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={title}
width="narrow"
showCloseButton={false}
footer={footer}
>
<div className={styles.body}>{description}</div>
</DialogWrapper>
);
}
export default ConfirmDeleteDialog;

View File

@@ -5,26 +5,23 @@
gap: 6px;
align-items: center;
max-width: 80%;
padding-left: 8px;
.dashboardBtn {
display: flex;
align-items: center;
.linkToPreviousPage {
// Collapse the design-system Button's fixed-height/padding box so it hugs
// the label like inline text (the breadcrumb is text, not a chunky button).
--button-height: auto;
--button-padding: 0;
--button-gap: 4px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboardBtn:hover {
background-color: unset;
}
.idBtn {
.currentPage {
display: flex;
align-items: center;
gap: 4px;
@@ -46,12 +43,9 @@
overflow: hidden;
text-overflow: ellipsis;
}
:global(.ant-btn-icon) {
margin-inline-end: 4px;
}
}
.idBtn:hover {
.currentPage:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}

View File

@@ -1,19 +1,23 @@
import { useCallback } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from '@signozhq/icons';
import styles from './DashboardBreadcrumbs.module.scss';
interface Props {
interface DashboardBreadcrumbsProps {
title: string;
image: string;
}
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
function DashboardBreadcrumbs({
title,
image,
}: DashboardBreadcrumbsProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
@@ -35,20 +39,23 @@ function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
<div className={styles.dashboardBreadcrumbs}>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutGrid size={14} />}
className={styles.dashboardBtn}
onClick={goToListPage}
className={styles.linkToPreviousPage}
testId="dashboard-breadcrumb-list"
>
Dashboard /
Dashboard
</Button>
<Button variant="ghost" className={styles.idBtn}>
<div>/</div>
<div className={styles.currentPage}>
<img
src={image}
alt="dashboard-icon"
className={styles.dashboardIconImage}
/>
{title}
</Button>
<Typography.Text>{title}</Typography.Text>
</div>
</div>
);
}

View File

@@ -5,12 +5,12 @@ import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import styles from './DashboardHeader.module.scss';
interface Props {
interface DashboardHeaderProps {
title: string;
image: string;
}
function DashboardHeader({ title, image }: Props): JSX.Element {
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
return (
<div className={styles.dashboardHeader}>
<DashboardBreadcrumbs title={title} image={image} />

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelectionModal';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
@@ -10,12 +11,15 @@ import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
interface Props {
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
function DashboardContainer({
dashboard,
refetch,
}: DashboardContainerProps): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
@@ -43,6 +47,9 @@ function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
/>
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
trigger; navigates to the widget editor route on selection. */}
<PanelTypeSelectionModal />
</FullScreen>
);
}

View File

@@ -24,7 +24,6 @@ import {
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import ListView from 'container/TracesExplorer/ListView';
import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/configs';
import QuerySection from 'container/TracesExplorer/QuerySection';
import TableView from 'container/TracesExplorer/TableView';
import TracesView from 'container/TracesExplorer/TracesView';
@@ -80,9 +79,6 @@ function TracesExplorer(): JSX.Element {
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();

View File

@@ -3,9 +3,9 @@ import { useEffect, useState } from 'react';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { DataSource } from 'types/common/queryBuilder';
@@ -69,7 +69,7 @@ export function usePreferenceSync({
};
}
if (dataSource === DataSource.TRACES) {
columns = parsedExtraData?.selectColumns || defaultTracesSelectedColumns;
columns = parsedExtraData?.selectColumns || defaultTraceSelectedColumns;
}
setSavedViewPreferences({ columns, formatting });
}, [viewsData, dataSource, savedViewId, mode]);

View File

@@ -826,4 +826,10 @@ body.ai-assistant-panel-open {
:root {
--input-focus-outline-width: 0;
--radius-2: 4px;
// @signozhq/ui dropdown content portals to body with a default z-index of 50,
// antd defines it at z-index of 1050. Keeping this till we have components from both
// design libraries.
--dropdown-menu-content-z-index: 1050;
--dropdown-menu-sub-content-z-index: 1050;
}

View File

@@ -26,7 +26,7 @@ type Alertmanager interface {
PutAlerts(context.Context, string, alertmanagertypes.PostableAlerts) error
// TestReceiver sends a test alert to a receiver.
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
TestReceiver(context.Context, string, *alertmanagertypes.Receiver) error
// TestAlert sends an alert to a list of receivers.
TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error
@@ -41,10 +41,10 @@ type Alertmanager interface {
GetChannelByID(context.Context, string, valuer.UUID) (*alertmanagertypes.Channel, error)
// UpdateChannel updates a channel for the organization.
UpdateChannelByReceiverAndID(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error
UpdateChannelByReceiverAndID(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error
// CreateChannel creates a channel for the organization.
CreateChannel(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error

View File

@@ -26,8 +26,8 @@ var customNotifierIntegrations = []string{
msteamsv2.Integration,
}
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(*nc.Receiver, tmpl, logger)
if err != nil {
return nil, err
}

View File

@@ -275,7 +275,11 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
continue
}
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
if err != nil {
return err
}
integrations, err := alertmanagernotify.NewReceiverIntegrations(extendedRcv, server.tmpl, server.logger, server.templater)
if err != nil {
return err
}
@@ -350,7 +354,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
return nil
}
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
func (server *Server) TestReceiver(ctx context.Context, receiver *alertmanagertypes.Receiver) error {
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
}

View File

@@ -75,7 +75,7 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
require.NoError(t, err)
err = server.TestReceiver(context.Background(), alertmanagertypes.Receiver{
err = server.TestReceiver(context.Background(), &alertmanagertypes.Receiver{Receiver: &config.Receiver{
Name: "test-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
@@ -83,7 +83,7 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
URL: config.SecretTemplateURL(webhookURL.String()),
},
},
})
}})
assert.NoError(t, err)
assert.Contains(t, requestBody.String(), "test-receiver")
@@ -101,7 +101,7 @@ func TestServerPutAlerts(t *testing.T) {
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
Name: "test-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
@@ -109,7 +109,7 @@ func TestServerPutAlerts(t *testing.T) {
URL: config.SecretTemplateURL("http://localhost/test-receiver"),
},
},
}))
}}))
require.NoError(t, server.SetConfig(context.Background(), amConfig))
@@ -181,7 +181,7 @@ func TestServerTestAlert(t *testing.T) {
webhook2URL, err := url.Parse("http://" + webhook2Listener.Addr().String() + "/webhook")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
Name: "receiver-1",
WebhookConfigs: []*config.WebhookConfig{
{
@@ -189,9 +189,9 @@ func TestServerTestAlert(t *testing.T) {
URL: config.SecretTemplateURL(webhook1URL.String()),
},
},
}))
}}))
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
Name: "receiver-2",
WebhookConfigs: []*config.WebhookConfig{
{
@@ -199,7 +199,7 @@ func TestServerTestAlert(t *testing.T) {
URL: config.SecretTemplateURL(webhook2URL.String()),
},
},
}))
}}))
require.NoError(t, server.SetConfig(context.Background(), amConfig))
defer func() {
@@ -273,7 +273,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
Name: "working-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
@@ -281,9 +281,9 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
URL: config.SecretTemplateURL(webhookURL.String()),
},
},
}))
}}))
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
Name: "failing-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
@@ -291,7 +291,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
URL: config.SecretTemplateURL("http://localhost:1/webhook"),
},
},
}))
}}))
require.NoError(t, server.SetConfig(context.Background(), amConfig))
defer func() {

View File

@@ -155,7 +155,7 @@ func (_c *MockAlertmanager_Config_Call) RunAndReturn(run func() alertmanagerserv
}
// CreateChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, v)
if len(ret) == 0 {
@@ -164,17 +164,17 @@ func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string,
var r0 *alertmanagertypes.Channel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
return returnFunc(context1, s, v)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
r0 = returnFunc(context1, s, v)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.Channel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
r1 = returnFunc(context1, s, v)
} else {
r1 = ret.Error(1)
@@ -190,12 +190,12 @@ type MockAlertmanager_CreateChannel_Call struct {
// CreateChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_CreateChannel_Call {
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, v)}
}
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -205,9 +205,9 @@ func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Con
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.Receiver
var arg2 *alertmanagertypes.Receiver
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.Receiver)
arg2 = args[2].(*alertmanagertypes.Receiver)
}
run(
arg0,
@@ -223,7 +223,7 @@ func (_c *MockAlertmanager_CreateChannel_Call) Return(channel *alertmanagertypes
return _c
}
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
_c.Call.Return(run)
return _c
}
@@ -1624,7 +1624,7 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
}
// TestReceiver provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v alertmanagertypes.Receiver) error {
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v *alertmanagertypes.Receiver) error {
ret := _mock.Called(context1, s, v)
if len(ret) == 0 {
@@ -1632,7 +1632,7 @@ func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string,
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
r0 = returnFunc(context1, s, v)
} else {
r0 = ret.Error(0)
@@ -1648,12 +1648,12 @@ type MockAlertmanager_TestReceiver_Call struct {
// TestReceiver is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_TestReceiver_Call {
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, v)}
}
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1663,9 +1663,9 @@ func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Cont
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.Receiver
var arg2 *alertmanagertypes.Receiver
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.Receiver)
arg2 = args[2].(*alertmanagertypes.Receiver)
}
run(
arg0,
@@ -1681,7 +1681,7 @@ func (_c *MockAlertmanager_TestReceiver_Call) Return(err error) *MockAlertmanage
return _c
}
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
_c.Call.Return(run)
return _c
}
@@ -1750,7 +1750,7 @@ func (_c *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call) RunAndReturn(run
}
// UpdateChannelByReceiverAndID provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error {
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error {
ret := _mock.Called(context1, s, v, uUID)
if len(ret) == 0 {
@@ -1758,7 +1758,7 @@ func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Con
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error); ok {
r0 = returnFunc(context1, s, v, uUID)
} else {
r0 = ret.Error(0)
@@ -1774,13 +1774,13 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
// UpdateChannelByReceiverAndID is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, v interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, v, uUID)}
}
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1790,9 +1790,9 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(conte
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.Receiver
var arg2 *alertmanagertypes.Receiver
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.Receiver)
arg2 = args[2].(*alertmanagertypes.Receiver)
}
var arg3 valuer.UUID
if args[3] != nil {
@@ -1813,7 +1813,7 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Return(err error)
return _c
}
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -138,7 +138,7 @@ func (service *Service) PutAlerts(ctx context.Context, orgID string, alerts aler
return server.PutAlerts(ctx, alerts)
}
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
service.serversMtx.RLock()
defer service.serversMtx.RUnlock()

View File

@@ -111,7 +111,7 @@ func (provider *provider) PutAlerts(ctx context.Context, orgID string, alerts al
return provider.service.PutAlerts(ctx, orgID, alerts)
}
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
return provider.service.TestReceiver(ctx, orgID, receiver)
}
@@ -152,7 +152,7 @@ func (provider *provider) GetChannelByID(ctx context.Context, orgID string, chan
return provider.configStore.GetChannelByID(ctx, orgID, channelID)
}
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver, id valuer.UUID) error {
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver, id valuer.UUID) error {
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
if err != nil {
return err
@@ -211,7 +211,7 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
}))
}
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
config, err := provider.configStore.Get(ctx, orgID)
if err != nil {
return nil, err

View File

@@ -15,6 +15,7 @@ import (
"github.com/tidwall/gjson"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"gopkg.in/yaml.v2"
)
type addAlertmanager struct {
@@ -246,12 +247,38 @@ func (migration *addAlertmanager) Down(ctx context.Context, db *bun.DB) error {
return nil
}
// copy of alertmanagertypes.NewReceiver as it existed
// when this migration was written.
func newReceiver(input string) (config.Receiver, error) {
receiver := config.Receiver{}
err := json.Unmarshal([]byte(input), &receiver)
if err != nil {
return config.Receiver{}, err
}
bytes, err := yaml.Marshal(receiver)
if err != nil {
return config.Receiver{}, err
}
receiverWithDefaults := config.Receiver{}
if err := yaml.Unmarshal(bytes, &receiverWithDefaults); err != nil {
return config.Receiver{}, err
}
if err := receiverWithDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return config.Receiver{}, err
}
return receiverWithDefaults, nil
}
func (migration *addAlertmanager) msTeamsChannelToMSTeamsV2Channel(c *alertmanagertypes.Channel) error {
if c.Type != "msteams" {
return nil
}
receiver, err := alertmanagertypes.NewReceiver(c.Data)
receiver, err := newReceiver(c.Data)
if err != nil {
return err
}
@@ -269,7 +296,7 @@ func (migration *addAlertmanager) msTeamsChannelToMSTeamsV2Channel(c *alertmanag
return nil
}
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver alertmanagertypes.Receiver) alertmanagertypes.Receiver {
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver config.Receiver) config.Receiver {
if receiver.MSTeamsConfigs == nil {
return receiver
}

View File

@@ -134,7 +134,7 @@ func NewAlertsFromPostableAlerts(ctx context.Context, postableAlerts PostableAle
return validAlerts, errs
}
func NewTestAlert(receiver Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
func NewTestAlert(receiver *Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
return &Alert{
Alert: model.Alert{
StartsAt: startsAt,

View File

@@ -56,7 +56,7 @@ type Channel struct {
// NewChannelFromReceiver creates a new Channel from a Receiver.
// It can return nil if the receiver is the default receiver.
func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, error) {
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
if receiver.Name == DefaultReceiverName {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
}
@@ -74,46 +74,13 @@ func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, e
OrgID: orgID,
}
// Use reflection to examine receiver struct fields
receiverType := reflect.TypeOf(receiver)
receiverVal := reflect.ValueOf(receiver)
// Iterate through fields looking for *Config fields
for i := 0; i < receiverType.NumField(); i++ {
field := receiverType.Field(i)
fieldVal := receiverVal.Field(i)
// Skip if not a slice or is empty
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
continue
}
// Get channel type from yaml tag
yamlTag := field.Tag.Get("yaml")
if yamlTag == "" {
continue
}
// Extract the base type name (e.g., "email_configs" -> "email")
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
if len(matches) != 2 {
continue
}
channelType := matches[1]
// Marshal config data to JSON
configData, err := json.Marshal(receiver)
if err != nil {
continue
}
channel.Type = channelType
channel.Data = string(configData)
break
data, err := json.Marshal(receiver)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "marshal receiver")
}
channel.Data = string(data)
// If we were unable to find the channel type, return an error
channel.Type = receiverChannelType(receiver)
if channel.Type == "" {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
}
@@ -121,6 +88,44 @@ func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, e
return &channel, nil
}
// receiverChannelType returns the channel.Type discriminator. Walks
// Receiver's own fields first (native), then the embed (upstream); first
// non-empty *_configs slice wins.
func receiverChannelType(receiver *Receiver) string {
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
return t
}
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
return t
}
return ""
}
func nonEmptyConfigsField(v reflect.Value) string {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fieldVal := v.Field(i)
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
continue
}
yamlTag := field.Tag.Get("yaml")
if yamlTag == "" {
continue
}
// Extract the base type name (e.g., "email_configs" -> "email").
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
if len(matches) != 2 {
continue
}
return matches[1]
}
return ""
}
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
cfg, err := NewDefaultConfig(
globalConfig,
@@ -182,7 +187,7 @@ func NewStatsFromChannels(channels Channels) map[string]any {
return stats
}
func (c *Channel) Update(receiver Receiver) error {
func (c *Channel) Update(receiver *Receiver) error {
channel, err := NewChannelFromReceiver(receiver, c.OrgID)
if err != nil {
return err
@@ -192,6 +197,7 @@ func (c *Channel) Update(receiver Receiver) error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
}
c.Type = channel.Type
c.Data = channel.Data
c.UpdatedAt = time.Now()
@@ -210,15 +216,19 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
schema.WithRequired("name")
var oneOf []jsonschema.SchemaOrBool
receiverType := reflect.TypeOf(Receiver{})
for i := 0; i < receiverType.NumField(); i++ {
jsonTag := strings.Split(receiverType.Field(i).Tag.Get("json"), ",")[0]
if !strings.HasSuffix(jsonTag, "_configs") {
continue
// Walk both halves: native fields on Receiver, upstream on the embed.
collect := func(t reflect.Type) {
for i := 0; i < t.NumField(); i++ {
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
if !strings.HasSuffix(jsonTag, "_configs") {
continue
}
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
oneOf = append(oneOf, branch.ToSchemaOrBool())
}
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
oneOf = append(oneOf, branch.ToSchemaOrBool())
}
collect(reflect.TypeOf(Receiver{}))
collect(reflect.TypeOf(config.Receiver{}))
schema.WithOneOf(oneOf...)

View File

@@ -285,7 +285,8 @@ func TestNewChannelFromReceiver(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
channel, err := NewChannelFromReceiver(testCase.receiver, "1")
receiver := testCase.receiver
channel, err := NewChannelFromReceiver(&Receiver{Receiver: &receiver}, "1")
if !testCase.pass {
assert.Error(t, err)
return
@@ -299,3 +300,31 @@ func TestNewChannelFromReceiver(t *testing.T) {
}
}
// Type and Data are derived from the native googlechat_configs field.
func TestNewChannelFromReceiverGoogleChat(t *testing.T) {
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
if err != nil {
t.Fatal(err)
}
receiver := &Receiver{
Receiver: &config.Receiver{Name: "googlechat-receiver"},
GoogleChatConfigs: []*GoogleChatReceiverConfig{
{
WebhookURL: &config.SecretURL{URL: webhookURL},
Title: "Alert",
Text: "Body",
},
},
}
channel, err := NewChannelFromReceiver(receiver, "1")
assert.NoError(t, err)
assert.Equal(t, "googlechat-receiver", channel.Name)
assert.Equal(t, "googlechat", channel.Type)
assert.JSONEq(t,
`{"name":"googlechat-receiver","googlechat_configs":[{"send_resolved":false,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
channel.Data,
)
}

View File

@@ -59,12 +59,43 @@ type Config struct {
// storeableConfig is the representation of the config in the store
storeableConfig *StoreableConfig
// customConfigs holds the custom notifier configs upstream's
// config.Receiver cannot carry, keyed by receiver name.
customConfigs map[string]customReceiverConfigs
}
// customReceiverConfigs is the per-receiver custom notifier
// configs. To add another, mirror GoogleChat: a field here, a matching field
// on Receiver, and extensions to customConfigsOf + isEmpty.
type customReceiverConfigs struct {
GoogleChat []*GoogleChatReceiverConfig
}
func (c customReceiverConfigs) isEmpty() bool {
return len(c.GoogleChat) == 0
}
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
return customReceiverConfigs{
GoogleChat: receiver.GoogleChatConfigs,
}
}
// storedConfig is the persistence unit. The outer Receivers shadows the
// embed's so receivers emit as the extended *Receiver (encoding/json:
// shallower-field-wins on duplicate JSON names).
type storedConfig struct {
*config.Config
Receivers []*Receiver `json:"receivers"`
}
func NewConfig(c *config.Config, orgID string) *Config {
raw := string(newRawFromConfig(c))
customConfigs := make(map[string]customReceiverConfigs)
raw := string(newRawFromConfig(c, customConfigs))
return &Config{
alertmanagerConfig: c,
customConfigs: customConfigs,
storeableConfig: &StoreableConfig{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -81,13 +112,14 @@ func NewConfig(c *config.Config, orgID string) *Config {
}
func NewConfigFromStoreableConfig(sc *StoreableConfig) (*Config, error) {
alertmanagerConfig, err := newConfigFromString(sc.Config)
alertmanagerConfig, customConfigs, err := newConfigFromString(sc.Config)
if err != nil {
return nil, err
}
return &Config{
alertmanagerConfig: alertmanagerConfig,
customConfigs: customConfigs,
storeableConfig: sc,
}, nil
}
@@ -113,32 +145,47 @@ func NewDefaultConfig(globalConfig GlobalConfig, routeConfig RouteConfig, orgID
}, orgID), nil
}
func newConfigFromString(s string) (*config.Config, error) {
config := new(config.Config)
err := json.Unmarshal([]byte(s), config)
if err != nil {
return nil, err
func newConfigFromString(s string) (*config.Config, map[string]customReceiverConfigs, error) {
stored := storedConfig{Config: new(config.Config)}
if err := json.Unmarshal([]byte(s), &stored); err != nil {
return nil, nil, err
}
for i, receiver := range config.Receivers {
bytes, err := json.Marshal(receiver)
if err != nil {
return nil, err
}
amConfig := stored.Config
amConfig.Receivers = make([]config.Receiver, len(stored.Receivers))
customConfigs := make(map[string]customReceiverConfigs)
receiver, err := NewReceiver(string(bytes))
// Re-run NewReceiver per receiver so defaults apply (mirrors create path).
for i, rcv := range stored.Receivers {
rcvJSON, err := json.Marshal(rcv)
if err != nil {
return nil, err
return nil, nil, err
}
parsed, err := NewReceiver(string(rcvJSON))
if err != nil {
return nil, nil, err
}
amConfig.Receivers[i] = *parsed.Receiver
if custom := customConfigsOf(parsed); !custom.isEmpty() {
customConfigs[parsed.Name] = custom
}
config.Receivers[i] = receiver
}
return config, nil
return amConfig, customConfigs, nil
}
func newRawFromConfig(c *config.Config) []byte {
b, err := json.Marshal(c)
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
receivers := make([]*Receiver, len(c.Receivers))
for i := range c.Receivers {
base := c.Receivers[i]
custom := customConfigs[base.Name]
receivers[i] = &Receiver{
Receiver: &base,
GoogleChatConfigs: custom.GoogleChat,
}
}
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
if err != nil {
// Taking inspiration from the upstream. This is never expected to happen.
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
@@ -151,6 +198,14 @@ func newConfigHash(s string) [16]byte {
return md5.Sum([]byte(s))
}
// flush refreshes the storeable representation. Call after any mutation.
func (c *Config) flush() {
raw := string(newRawFromConfig(c.alertmanagerConfig, c.customConfigs))
c.storeableConfig.Config = raw
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(raw))
c.storeableConfig.UpdatedAt = time.Now()
}
func (c *Config) CopyWithReset() (*Config, error) {
newConfig, err := NewDefaultConfig(
*c.alertmanagerConfig.Global,
@@ -179,9 +234,7 @@ func (c *Config) SetGlobalConfig(globalConfig GlobalConfig) error {
globalConfig.SMTPRequireTLS = smtpRequireTLS
c.alertmanagerConfig.Global = &globalConfig
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -193,9 +246,7 @@ func (c *Config) SetRouteConfig(routeConfig RouteConfig) error {
}
c.alertmanagerConfig.Route = route
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -207,9 +258,7 @@ func (c *Config) AddInhibitRules(rules []config.InhibitRule) error {
c.alertmanagerConfig.InhibitRules = append(c.alertmanagerConfig.InhibitRules, rules...)
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -222,7 +271,7 @@ func (c *Config) StoreableConfig() *StoreableConfig {
return c.storeableConfig
}
func (c *Config) CreateReceiver(receiver config.Receiver) error {
func (c *Config) CreateReceiver(receiver *Receiver) error {
// check that receiver name is not already used
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == receiver.Name {
@@ -236,33 +285,39 @@ func (c *Config) CreateReceiver(receiver config.Receiver) error {
}
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, receiver)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
c.setCustomConfigs(receiver)
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return err
}
c.applyNativeDefaults()
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
func (c *Config) GetReceiver(name string) (Receiver, error) {
for _, receiver := range c.alertmanagerConfig.Receivers {
if receiver.Name == name {
return receiver, nil
func (c *Config) GetReceiver(name string) (*Receiver, error) {
for i := range c.alertmanagerConfig.Receivers {
if c.alertmanagerConfig.Receivers[i].Name == name {
base := c.alertmanagerConfig.Receivers[i]
custom := c.customConfigs[name]
return &Receiver{
Receiver: &base,
GoogleChatConfigs: custom.GoogleChat,
}, nil
}
}
return Receiver{}, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
return nil, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
}
func (c *Config) UpdateReceiver(receiver config.Receiver) error {
func (c *Config) UpdateReceiver(receiver *Receiver) error {
// find and update receiver
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == receiver.Name {
c.alertmanagerConfig.Receivers[i] = receiver
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
c.setCustomConfigs(receiver)
break
}
}
@@ -270,10 +325,9 @@ func (c *Config) UpdateReceiver(receiver config.Receiver) error {
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return err
}
c.applyNativeDefaults()
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -298,13 +352,36 @@ func (c *Config) DeleteReceiver(name string) error {
}
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
delete(c.customConfigs, name)
c.flush()
return nil
}
func (c *Config) setCustomConfigs(receiver *Receiver) {
if custom := customConfigsOf(receiver); !custom.isEmpty() {
c.customConfigs[receiver.Name] = custom
} else {
delete(c.customConfigs, receiver.Name)
}
}
func (c *Config) applyNativeDefaults() {
if c.alertmanagerConfig.Global == nil {
return
}
httpDefault := c.alertmanagerConfig.Global.HTTPConfig
for _, custom := range c.customConfigs {
for _, gc := range custom.GoogleChat {
if gc.HTTPConfig == nil {
gc.HTTPConfig = httpDefault
}
}
}
}
func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) error {
if c.alertmanagerConfig.Route == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigInvalid, "route is nil")
@@ -318,9 +395,7 @@ func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) erro
}
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -339,9 +414,7 @@ func (c *Config) DeleteRuleIDInhibitor(ruleID string) error {
}
}
c.alertmanagerConfig.InhibitRules = filteredRules
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -362,9 +435,7 @@ func (c *Config) DeleteRuleIDMatcher(ruleID string) error {
}
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}

View File

@@ -108,7 +108,7 @@ func TestCreateRuleIDMatcher(t *testing.T) {
require.NoError(t, err)
for _, receiver := range tc.receivers {
err := cfg.CreateReceiver(receiver)
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
require.NoError(t, err)
}
@@ -203,7 +203,7 @@ func TestDeleteRuleIDMatcher(t *testing.T) {
require.NoError(t, err)
for _, receiver := range tc.receivers {
err := cfg.CreateReceiver(receiver)
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
require.NoError(t, err)
}
@@ -329,3 +329,58 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
})
}
}
// Round-trip: create → serialize → reload → GetReceiver still has the configs.
func TestConfigPreservesGoogleChatConfigs(t *testing.T) {
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
require.NoError(t, err)
cfg, err := NewDefaultConfig(
GlobalConfig{SMTPSmarthost: config.HostPort{Host: "localhost", Port: "25"}, SMTPFrom: "test@example.com"},
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
"1",
)
require.NoError(t, err)
receiver := &Receiver{
Receiver: &config.Receiver{Name: "googlechat-receiver"},
GoogleChatConfigs: []*GoogleChatReceiverConfig{
{
WebhookURL: &config.SecretURL{URL: webhookURL},
Title: "Alert",
Text: "Body",
},
},
}
require.NoError(t, cfg.CreateReceiver(receiver))
got, err := cfg.GetReceiver("googlechat-receiver")
require.NoError(t, err)
require.Len(t, got.GoogleChatConfigs, 1)
assert.Equal(t, "Alert", got.GoogleChatConfigs[0].Title)
assert.Equal(t, "Body", got.GoogleChatConfigs[0].Text)
// HTTPConfig threaded from Global by applyNativeDefaults.
require.NotNil(t, got.GoogleChatConfigs[0].HTTPConfig)
assert.Same(t, cfg.alertmanagerConfig.Global.HTTPConfig, got.GoogleChatConfigs[0].HTTPConfig)
reloaded, err := NewConfigFromStoreableConfig(cfg.StoreableConfig())
require.NoError(t, err)
reloadedReceiver, err := reloaded.GetReceiver("googlechat-receiver")
require.NoError(t, err)
require.Len(t, reloadedReceiver.GoogleChatConfigs, 1)
assert.Equal(t, "Alert", reloadedReceiver.GoogleChatConfigs[0].Title)
assert.Equal(t, "Body", reloadedReceiver.GoogleChatConfigs[0].Text)
assert.Equal(t, "https://chat.googleapis.com/v1/spaces/test/messages", reloadedReceiver.GoogleChatConfigs[0].WebhookURL.String())
require.NotNil(t, reloadedReceiver.GoogleChatConfigs[0].HTTPConfig)
receiver.GoogleChatConfigs[0].Title = "Updated"
require.NoError(t, cfg.UpdateReceiver(receiver))
updated, err := cfg.GetReceiver("googlechat-receiver")
require.NoError(t, err)
require.Len(t, updated.GoogleChatConfigs, 1)
assert.Equal(t, "Updated", updated.GoogleChatConfigs[0].Title)
}

View File

@@ -0,0 +1,34 @@
package alertmanagertypes
import (
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
type GoogleChatReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
}
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{
NotifierConfig: config.NotifierConfig{
VSendResolved: false,
},
Title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
Text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
*Summary:* {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
*Description:* {{ .Annotations.description }}{{ end }}
{{ end }}`,
}
func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
*c = DefaultGoogleChatReceiverConfig
type plain GoogleChatReceiverConfig
return unmarshal((*plain)(c))
}

View File

@@ -22,7 +22,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
{
name: "Simple",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
require.NoError(t, err)
return route
@@ -33,7 +33,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
{
name: "AlreadyExists",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
require.NoError(t, err)
err = addRuleIDToRoute(route, "1")
@@ -84,7 +84,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
{
name: "Simple",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
require.NoError(t, err)
err = addRuleIDToRoute(route, "1")
@@ -98,7 +98,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
{
name: "DoesNotExist",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
require.NoError(t, err)
return route
@@ -109,7 +109,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
{
name: "DeleteMatcher",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
require.NoError(t, err)
return route

View File

@@ -17,4 +17,4 @@ type Templater interface {
// ReceiverIntegrationsFunc constructs the notify.Integration list for a
// configured receiver.
type ReceiverIntegrationsFunc = func(nc Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)
type ReceiverIntegrationsFunc = func(nc *Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)

View File

@@ -17,40 +17,73 @@ import (
"github.com/prometheus/alertmanager/config"
)
type (
// Receiver is the type for the receiver configuration.
Receiver = config.Receiver
)
// Creates a new receiver from a string. The input is initialized with the default values from the upstream alertmanager.
// The only default value which is missed is `send_resolved` (as it is a bool) which if not set in the input will always be set to `false`.
func NewReceiver(input string) (Receiver, error) {
receiver := Receiver{}
err := json.Unmarshal([]byte(input), &receiver)
if err != nil {
return Receiver{}, err
}
// We marshal and unmarshal the receiver to ensure that the receiver is
// initialized with defaults from the upstream alertmanager.
bytes, err := yaml.Marshal(receiver)
if err != nil {
return Receiver{}, err
}
receiverWithDefaults := Receiver{}
if err := yaml.Unmarshal(bytes, &receiverWithDefaults); err != nil {
return Receiver{}, err
}
if err := receiverWithDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return Receiver{}, err
}
return receiverWithDefaults, nil
// Receiver embeds upstream config.Receiver to support custom receivers
// To add another native notifier, mirror GoogleChatConfigs here
// and extend customReceiverConfigs in config.go.
type Receiver struct {
*config.Receiver
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
}
func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
// NewReceiver builds a Receiver from its JSON input, applying each notifier
// config's per-config defaults via UnmarshalYAML.
func NewReceiver(input string) (*Receiver, error) {
receiver := &Receiver{Receiver: &config.Receiver{}}
if err := json.Unmarshal([]byte(input), receiver); err != nil {
return nil, err
}
withDefaults, err := defaultedBaseReceiver(receiver.Receiver)
if err != nil {
return nil, err
}
receiver.Receiver = withDefaults
// Extend this block when adding another native notifier type.
for i, gc := range receiver.GoogleChatConfigs {
defaulted, err := defaultedNotifierConfig(gc)
if err != nil {
return nil, err
}
receiver.GoogleChatConfigs[i] = defaulted
}
return receiver, nil
}
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
bytes, err := yaml.Marshal(base)
if err != nil {
return nil, err
}
withDefaults := &config.Receiver{}
if err := yaml.Unmarshal(bytes, withDefaults); err != nil {
return nil, err
}
if err := withDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return nil, err
}
return withDefaults, nil
}
// defaultedNotifierConfig triggers T.UnmarshalYAML via a yaml round-trip,
// installing T's DefaultXxxConfig over user values.
func defaultedNotifierConfig[T any](cfg *T) (*T, error) {
bytes, err := yaml.Marshal(cfg)
if err != nil {
return nil, err
}
out := new(T)
if err := yaml.Unmarshal(bytes, out); err != nil {
return nil, err
}
return out, nil
}
func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
ctx = notify.WithGroupKey(ctx, fmt.Sprintf("%s-%s-%d", receiver.Name, lSet.Fingerprint(), time.Now().Unix()))
ctx = notify.WithGroupLabels(ctx, lSet)
ctx = notify.WithReceiverName(ctx, receiver.Name)
@@ -67,12 +100,12 @@ func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFu
return err
}
receiver, err = testConfig.GetReceiver(receiver.Name)
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
if err != nil {
return err
}
integrations, err := receiverIntegrationsFunc(receiver, tmpl, logger, templater)
integrations, err := receiverIntegrationsFunc(defaultedReceiver, tmpl, logger, templater)
if err != nil {
return err
}

View File

@@ -21,6 +21,12 @@ func TestNewReceiver(t *testing.T) {
expected: `{"name":"telegram","telegram_configs":[{"send_resolved":false,"token":"1234567890","chat":12345,"message":"{{ template \"telegram.default.message\" . }}","parse_mode":"HTML"}]}`,
pass: true,
},
{
name: "GoogleChatConfig",
input: `{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
expected: `{"name":"googlechat","googlechat_configs":[{"send_resolved":false,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
pass: true,
},
}
for _, tc := range testCases {
@@ -39,3 +45,27 @@ func TestNewReceiver(t *testing.T) {
})
}
}
// Omitted fields fall back to DefaultGoogleChatReceiverConfig.
func TestNewReceiverGoogleChatAppliesDefaults(t *testing.T) {
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages"}]}`)
require.NoError(t, err)
require.Len(t, receiver.GoogleChatConfigs, 1)
got := receiver.GoogleChatConfigs[0]
assert.Equal(t, DefaultGoogleChatReceiverConfig.Title, got.Title, "Title should fall back to the default template")
assert.Equal(t, DefaultGoogleChatReceiverConfig.Text, got.Text, "Text should fall back to the default template")
assert.Equal(t, DefaultGoogleChatReceiverConfig.VSendResolved, got.SendResolved(), "send_resolved should fall back to the default")
}
// User-specified values override defaults.
func TestNewReceiverGoogleChatPreservesUserOverrides(t *testing.T) {
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"X","text":"Y","send_resolved":true}]}`)
require.NoError(t, err)
require.Len(t, receiver.GoogleChatConfigs, 1)
got := receiver.GoogleChatConfigs[0]
assert.Equal(t, "X", got.Title)
assert.Equal(t, "Y", got.Text)
assert.True(t, got.SendResolved())
}

View File

@@ -28,7 +28,7 @@ func NewRouteFromRouteConfig(route *config.Route, cfg RouteConfig) (*config.Rout
return route, nil
}
func NewRouteFromReceiver(receiver Receiver) (*config.Route, error) {
func NewRouteFromReceiver(receiver *Receiver) (*config.Route, error) {
route := &config.Route{Receiver: receiver.Name, Continue: true, Matchers: config.Matchers{noRuleIDMatcher}}
if err := route.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return nil, err

View File

@@ -151,7 +151,6 @@ def test_get_service_details_without_account(
assert "overview" in data, "Service should have 'overview' (markdown)"
assert "assets" in data, "Service should have 'assets'"
assert isinstance(data["assets"]["dashboards"], list), "assets.dashboards should be a list"
assert "telemetryCollectionStrategy" in data, "Service should have 'telemetryCollectionStrategy'"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
@@ -484,12 +483,12 @@ def test_enable_metrics_provisions_dashboards(
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
provisioned_ids = set()
for dash in dashboards_in_service:
assert "id" in dash, f"Dashboard entry missing 'id': {dash}"
assert "integrationDashboard" in dash, f"Integration dashboard entry missing"
try:
uuid.UUID(dash["id"])
uuid.UUID(dash["integrationDashboard"]["id"])
except ValueError as err:
raise AssertionError(f"Dashboard id '{dash['id']}' is not a UUID — dashboard was not provisioned") from err
provisioned_ids.add(dash["id"])
raise AssertionError(f"Dashboard id '{dash['integrationDashboard']['id']}' is not a UUID — dashboard was not provisioned") from err
provisioned_ids.add(dash["integrationDashboard"]["dashboardId"])
# Assertion 2: Provisioned dashboard IDs are present in the DB
with signoz.sqlstore.conn.connect() as conn:
@@ -539,7 +538,7 @@ def test_disable_metrics_deprovisions_dashboards(
timeout=10,
)
assert get_svc_response.status_code == HTTPStatus.OK
provisioned_ids = {d["id"] for d in get_svc_response.json()["data"]["assets"]["dashboards"]}
provisioned_ids = {d["integrationDashboard"]["dashboardId"] for d in get_svc_response.json()["data"]["assets"]["dashboards"]}
assert len(provisioned_ids) > 0, "Expected dashboards to be provisioned after enabling metrics"
# Disable metrics