mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-30 23:30:40 +01:00
Compare commits
4 Commits
main
...
feat/ai-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61f6370f45 | ||
|
|
f8b22c0feb | ||
|
|
9e3a6bb35d | ||
|
|
f5f019f61b |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,6 +68,7 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -184,7 +184,6 @@ components:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
@@ -218,7 +217,6 @@ components:
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
@@ -260,7 +258,6 @@ components:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
@@ -271,7 +268,6 @@ components:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
@@ -350,7 +346,6 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
@@ -379,7 +374,6 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
@@ -457,7 +451,6 @@ components:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
nullable: true
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
@@ -471,7 +464,6 @@ components:
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
nullable: true
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
@@ -1771,15 +1763,12 @@ components:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
timestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- timestampMillis
|
||||
- data
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
@@ -2024,8 +2013,6 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
required:
|
||||
- account_id
|
||||
- cloud_account_id
|
||||
@@ -2035,7 +2022,6 @@ components:
|
||||
- providerAccountId
|
||||
- integrationConfig
|
||||
- removedAt
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesGettableServicesMetadata:
|
||||
properties:
|
||||
@@ -2135,9 +2121,6 @@ components:
|
||||
type: object
|
||||
providerAccountId:
|
||||
type: string
|
||||
syncedVersion:
|
||||
nullable: true
|
||||
type: integer
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
@@ -2150,18 +2133,6 @@ components:
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesRegionState:
|
||||
enum:
|
||||
- enabled
|
||||
- disabled
|
||||
type: string
|
||||
CloudintegrationtypesRegionSyncState:
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionState'
|
||||
required:
|
||||
- state
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
@@ -2303,23 +2274,6 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesSyncState:
|
||||
nullable: true
|
||||
properties:
|
||||
inSync:
|
||||
type: boolean
|
||||
regions:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionSyncState'
|
||||
type: object
|
||||
version:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- version
|
||||
- inSync
|
||||
- regions
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
@@ -9693,6 +9647,17 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10007,6 +9972,84 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
db_name:
|
||||
type: string
|
||||
db_operation:
|
||||
type: string
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
external_http_method:
|
||||
type: string
|
||||
external_http_url:
|
||||
type: string
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
type: boolean
|
||||
http_host:
|
||||
type: string
|
||||
http_method:
|
||||
type: string
|
||||
http_url:
|
||||
type: string
|
||||
is_remote:
|
||||
type: string
|
||||
kind_string:
|
||||
type: string
|
||||
level:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
response_status_code:
|
||||
type: string
|
||||
span_id:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
sub_tree_node_count:
|
||||
minimum: 0
|
||||
type: integer
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -15731,6 +15774,80 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pages are fetched with the returned nextCursor.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: cursor
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -183,52 +183,32 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
// The agent uses this response to clean up resources
|
||||
if account.RemovedAt != nil {
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
// update account with cloud provider account id and agent report (heartbeat)
|
||||
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
err = module.store.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
// Get account as domain object for config access (enabled regions, etc.)
|
||||
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,7 +223,8 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
|
||||
// Delegate integration config building entirely to the provider module
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,7 +234,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
account.ID.StringValue(),
|
||||
integrationConfig,
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||
"LOGS": "SigNoz | Logs",
|
||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||
"PASSWORD_RESET": "SigNoz | Password Reset",
|
||||
"LIST_LICENSES": "SigNoz | List of Licenses",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||
"LOGS": "SigNoz | Logs",
|
||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
|
||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||
"PASSWORD_RESET": "SigNoz | Password Reset",
|
||||
|
||||
@@ -300,6 +300,14 @@ describe('PrivateRoute', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect /logs-explorer/live to /logs/logs-explorer/live', () => {
|
||||
renderPrivateRoute({ initialRoute: '/logs-explorer/live' });
|
||||
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
'/logs/logs-explorer/live',
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect /logs-save-views to /logs/saved-views', () => {
|
||||
renderPrivateRoute({ initialRoute: '/logs-save-views' });
|
||||
|
||||
@@ -1588,6 +1596,7 @@ describe('PrivateRoute', () => {
|
||||
// route definition comes last, and both keys are authz-aware either way.
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
|
||||
METRICS_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
|
||||
@@ -154,6 +154,10 @@ export const Logs = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
export const LiveLogs = Loadable(
|
||||
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
|
||||
);
|
||||
|
||||
export const PipelinePage = Loadable(
|
||||
() => import(/* webpackChunkName: "Pipelines" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
LogsIndexToFields,
|
||||
@@ -281,6 +282,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'LOGS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LIVE_LOGS,
|
||||
exact: true,
|
||||
component: LiveLogs,
|
||||
key: 'LIVE_LOGS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LOGS_PIPELINES,
|
||||
exact: true,
|
||||
@@ -513,6 +521,7 @@ export const LIST_LICENSES: AppRoutes = {
|
||||
export const oldNewRoutesMapping: Record<string, string> = {
|
||||
'/pipelines': '/logs/pipelines',
|
||||
'/logs-explorer': '/logs/logs-explorer',
|
||||
'/logs-explorer/live': '/logs/logs-explorer/live',
|
||||
'/logs-save-views': '/logs/saved-views',
|
||||
'/traces-save-views': '/traces/saved-views',
|
||||
'/settings/access-tokens': '/settings/service-accounts',
|
||||
|
||||
@@ -104,9 +104,9 @@ export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[] | null;
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -125,9 +125,9 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[] | null;
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -166,19 +166,13 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
|
||||
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
@@ -245,16 +239,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
|
||||
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -277,7 +265,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
@@ -319,16 +307,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
|
||||
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -344,7 +326,7 @@ export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
@@ -441,16 +423,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
|
||||
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -458,7 +434,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
@@ -474,9 +450,9 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[] | null;
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -567,23 +543,17 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
|
||||
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
@@ -3396,37 +3366,6 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
|
||||
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesRegionStateDTO {
|
||||
enabled = 'enabled',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export interface CloudintegrationtypesRegionSyncStateDTO {
|
||||
state: CloudintegrationtypesRegionStateDTO;
|
||||
}
|
||||
|
||||
export type CloudintegrationtypesSyncStateDTORegions = {
|
||||
[key: string]: CloudintegrationtypesRegionSyncStateDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesSyncStateDTO = {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
inSync: boolean;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
regions: CloudintegrationtypesSyncStateDTORegions;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
version: number;
|
||||
} | null;
|
||||
|
||||
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -3445,7 +3384,6 @@ export type CloudintegrationtypesAgentReportDTO = {
|
||||
* @type object,null
|
||||
*/
|
||||
data: CloudintegrationtypesAgentReportDTOData;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -3874,7 +3812,6 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
removedAt: string | null;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||
@@ -3945,10 +3882,6 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
* @type string
|
||||
*/
|
||||
providerAccountId?: string;
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
syncedVersion?: number | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
@@ -11209,6 +11142,157 @@ export interface SpantypesOtelSpanRefDTO {
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesThreadSpanDTOAttributesAnyOf = { [key: string]: unknown };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesThreadSpanDTOAttributes =
|
||||
SpantypesThreadSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesThreadSpanDTOResourceAnyOf = { [key: string]: string };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesThreadSpanDTOResource =
|
||||
SpantypesThreadSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesThreadSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesThreadSpanDTOAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_operation?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
events?: SpantypesEventDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_url?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_children?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_host?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
is_remote?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesThreadSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
response_status_code?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
status_code?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
sub_tree_node_count?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_state?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceThreadDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesThreadSpanDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -12882,6 +12966,30 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceThreadPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceThreadParams = {
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
export type GetTraceThread200 = {
|
||||
data: SpantypesGettableTraceThreadDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -16,6 +22,9 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -27,6 +36,26 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -127,6 +156,121 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pages are fetched with the returned nextCursor.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -106,7 +106,7 @@ describe.each([
|
||||
renderWithStore(dataSource);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAccessibleName('Download');
|
||||
expect(button).toHaveClass('periscope-btn', 'ghost');
|
||||
});
|
||||
|
||||
it('shows popover with export options when download button is clicked', () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
@@ -112,9 +111,8 @@ export default function DownloadOptionsMenu({
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Download size={16} />}
|
||||
type="primary"
|
||||
icon={<Download size={16} />}
|
||||
onClick={handleExport}
|
||||
className="export-button"
|
||||
disabled={isDownloading}
|
||||
@@ -146,14 +144,16 @@ export default function DownloadOptionsMenu({
|
||||
>
|
||||
<Tooltip title="Download" placement="top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Download size={14} />}
|
||||
aria-label="Download"
|
||||
className="periscope-btn ghost"
|
||||
icon={
|
||||
isDownloading ? (
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)
|
||||
}
|
||||
data-testid={`periscope-btn-download-${dataSource}`}
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DrawerProps } from 'antd';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ jest.mock('periscope/components/DataViewer', () => ({
|
||||
}));
|
||||
|
||||
// Force v2 for these tests regardless of route.
|
||||
jest.mock('../useIsLogDetailsV2', () => ({
|
||||
useIsLogDetailsV2: (): boolean => true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
id: 'log-1',
|
||||
timestamp: '2024-01-15T09:45:30Z',
|
||||
@@ -54,7 +58,7 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
|
||||
);
|
||||
}
|
||||
|
||||
describe('LogDetail drawer — header', () => {
|
||||
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import cx from 'classnames';
|
||||
import LogStateIndicator, {
|
||||
LogType,
|
||||
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -13,24 +19,33 @@ import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import Overview from 'container/LogDetailedView/Overview';
|
||||
import {
|
||||
aggregateAttributesResourcesToString,
|
||||
getSanitizedLogBody,
|
||||
removeEscapeCharacters,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Compass,
|
||||
Copy,
|
||||
Filter,
|
||||
Histogram,
|
||||
Table,
|
||||
TextSelect,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -41,6 +56,7 @@ import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -48,8 +64,11 @@ import './LogDetails.styles.scss';
|
||||
function LogDetailInner({
|
||||
log,
|
||||
onClose,
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
selectedTab,
|
||||
isListViewPanel = false,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
logs,
|
||||
onNavigateLog,
|
||||
@@ -62,6 +81,7 @@ function LogDetailInner({
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
initialContextQuery,
|
||||
);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
|
||||
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
|
||||
@@ -74,6 +94,8 @@ function LogDetailInner({
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
@@ -151,6 +173,12 @@ function LogDetailInner({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { onLogCopy } = useCopyLogLink(log?.id);
|
||||
|
||||
const LogJsonData = log ? aggregateAttributesResourcesToString(log) : '';
|
||||
|
||||
const handleModeChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
setIsEdit(false);
|
||||
@@ -192,6 +220,13 @@ function LogDetailInner({
|
||||
[logBody],
|
||||
);
|
||||
|
||||
const handleJSONCopy = (): void => {
|
||||
copyToClipboard(LogJsonData);
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
const handleQueryExpressionChange = useCallback(
|
||||
(value: string, queryIndex: number) => {
|
||||
// update the query at the given index
|
||||
@@ -260,6 +295,8 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
const logType = log?.attributes_string?.log_level || LogType.INFO;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -267,15 +304,69 @@ function LogDetailInner({
|
||||
maskClosable={false}
|
||||
getContainer={getContainer}
|
||||
title={
|
||||
<LogDetailsHeader
|
||||
log={log}
|
||||
onNavigatePrev={goToPrev}
|
||||
onNavigateNext={goToNext}
|
||||
isPrevDisabled={isPrevDisabled}
|
||||
isNextDisabled={isNextDisabled}
|
||||
showOpenInExplorer={!!handleOpenInExplorer}
|
||||
onOpenInExplorer={handleOpenInExplorer}
|
||||
/>
|
||||
isLogDetailsV2 ? (
|
||||
<LogDetailsHeader
|
||||
log={log}
|
||||
onNavigatePrev={goToPrev}
|
||||
onNavigateNext={goToNext}
|
||||
isPrevDisabled={isPrevDisabled}
|
||||
isNextDisabled={isNextDisabled}
|
||||
showOpenInExplorer={!!handleOpenInExplorer}
|
||||
onOpenInExplorer={handleOpenInExplorer}
|
||||
/>
|
||||
) : (
|
||||
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__title-left">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
|
||||
<Typography.Text className="title">Log details</Typography.Text>
|
||||
</div>
|
||||
<div className="log-detail-drawer__title-right">
|
||||
<div className="log-arrows">
|
||||
<Tooltip
|
||||
title={isPrevDisabled ? '' : 'Move to previous log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-up"
|
||||
disabled={isPrevDisabled}
|
||||
onClick={goToPrev}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isNextDisabled ? '' : 'Move to next log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-down"
|
||||
disabled={isNextDisabled}
|
||||
onClick={goToNext}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{handleOpenInExplorer && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
className="open-in-explorer-btn"
|
||||
onClick={handleOpenInExplorer}
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
placement="right"
|
||||
onClose={drawerCloseHandler}
|
||||
@@ -294,11 +385,15 @@ function LogDetailInner({
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
{isLogDetailsV2 ? (
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
) : (
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
)}
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
@@ -310,9 +405,9 @@ function LogDetailInner({
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
|
||||
<LogHighlights log={log} />
|
||||
{isLogDetailsV2 && <LogHighlights log={log} />}
|
||||
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
@@ -330,6 +425,21 @@ function LogDetailInner({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
|
||||
// JSON tab is redundant.
|
||||
...(isLogDetailsV2
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: VIEW_TYPES.JSON,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Braces size={14} />
|
||||
JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
@@ -368,6 +478,26 @@ function LogDetailInner({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* V2 moves copy actions into the header ⋯ menu */}
|
||||
{!isLogDetailsV2 && (
|
||||
<Tooltip
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={12} />}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
@@ -384,11 +514,19 @@ function LogDetailInner({
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
logData={log}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onClickActionItem}
|
||||
isListViewPanel={isListViewPanel}
|
||||
selectedOptions={options}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
|
||||
<JsonView data={LogJsonData} height="68vh" />
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
log={log}
|
||||
|
||||
11
frontend/src/components/LogDetail/useIsLogDetailsV2.ts
Normal file
11
frontend/src/components/LogDetail/useIsLogDetailsV2.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useIsLogDetailsV2(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
pathname === ROUTES.LOGS_EXPLORER ||
|
||||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
|
||||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/Logs/CopyClipboardHOC.tsx
Normal file
54
frontend/src/components/Logs/CopyClipboardHOC.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Popover } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
|
||||
function CopyClipboardHOC({
|
||||
entityKey,
|
||||
textToCopy,
|
||||
tooltipText = 'Copy to clipboard',
|
||||
children,
|
||||
}: CopyClipboardHOCProps): JSX.Element {
|
||||
const [value, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
useEffect(() => {
|
||||
if (value.value) {
|
||||
const key = entityKey || '';
|
||||
|
||||
const notificationMessage = `${key} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
}, [value, notifications, entityKey]);
|
||||
|
||||
const onClick = useCallback((): void => {
|
||||
setCopy(textToCopy);
|
||||
}, [setCopy, textToCopy]);
|
||||
|
||||
return (
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
overlayClassName="drawer-popover"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyClipboardHOCProps {
|
||||
entityKey: string | undefined;
|
||||
textToCopy: string;
|
||||
tooltipText?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default CopyClipboardHOC;
|
||||
CopyClipboardHOC.defaultProps = {
|
||||
tooltipText: 'Copy to clipboard',
|
||||
};
|
||||
@@ -38,6 +38,7 @@ const ROUTES = {
|
||||
LOGS_BASE: '/logs',
|
||||
LOGS: '/logs/logs-explorer',
|
||||
LOGS_EXPLORER: '/logs/logs-explorer',
|
||||
LIVE_LOGS: '/logs/logs-explorer/live',
|
||||
LOGS_PIPELINES: '/logs/pipelines',
|
||||
PASSWORD_RESET: '/password-reset',
|
||||
LIST_LICENSES: '/licenses',
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid2X2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
EXPLORER_ACTION_EVENTS,
|
||||
getExplorerActionEventPayload,
|
||||
getExportPanelType,
|
||||
} from './utils';
|
||||
|
||||
function AddToDashboardButton({
|
||||
query,
|
||||
sourcepage,
|
||||
panelType,
|
||||
isOneChartPerQuery = false,
|
||||
}: {
|
||||
query: Query | null;
|
||||
sourcepage: DataSource;
|
||||
panelType?: PANEL_TYPES;
|
||||
isOneChartPerQuery?: boolean;
|
||||
}): JSX.Element {
|
||||
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
|
||||
const { panelType: contextPanelType } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const open = (): void => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
void logEvent(
|
||||
EXPLORER_ACTION_EVENTS[sourcepage].addToDashboard,
|
||||
getExplorerActionEventPayload({
|
||||
sourcepage,
|
||||
panelType: contextPanelType,
|
||||
isOneChartPerQuery,
|
||||
}),
|
||||
);
|
||||
setQueryToExport(query);
|
||||
};
|
||||
|
||||
const handleExport = (
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
): void => {
|
||||
if (!dashboard || !queryToExport) {
|
||||
return;
|
||||
}
|
||||
const exportPanelType = panelType ?? getExportPanelType(contextPanelType);
|
||||
|
||||
void logEvent(EXPLORER_ACTION_EVENTS[sourcepage].exported, {
|
||||
...getExplorerActionEventPayload({
|
||||
sourcepage,
|
||||
panelType: contextPanelType,
|
||||
isOneChartPerQuery,
|
||||
}),
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
|
||||
const link = getExportToDashboardLink({
|
||||
query: queryToExport,
|
||||
panelType: exportPanelType,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId: v4(),
|
||||
});
|
||||
if (link) {
|
||||
safeNavigate(link);
|
||||
}
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={!query}
|
||||
onClick={open}
|
||||
prefix={<Grid2X2 size={16} />}
|
||||
aria-label="Add to dashboard"
|
||||
data-testid="explorer-add-to-dashboard"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSimple title="Add to dashboard">{button}</TooltipSimple>
|
||||
<ExportPanelContainer
|
||||
open={queryToExport !== null}
|
||||
onClose={(): void => setQueryToExport(null)}
|
||||
query={queryToExport}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddToDashboardButton;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ConciergeBell } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
EXPLORER_ACTION_EVENTS,
|
||||
getCreateAlertLink,
|
||||
getExplorerActionEventPayload,
|
||||
} from './utils';
|
||||
|
||||
function CreateAlertButton({
|
||||
query,
|
||||
sourcepage,
|
||||
isOneChartPerQuery = false,
|
||||
}: {
|
||||
query: Query | null;
|
||||
sourcepage: DataSource;
|
||||
isOneChartPerQuery?: boolean;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { panelType } = useQueryBuilder();
|
||||
|
||||
const createAlert = (): void => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
void logEvent(
|
||||
EXPLORER_ACTION_EVENTS[sourcepage].createAlert,
|
||||
getExplorerActionEventPayload({ sourcepage, panelType, isOneChartPerQuery }),
|
||||
);
|
||||
history.push(getCreateAlertLink({ query, panelType }));
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size={isOneChartPerQuery ? 'icon' : 'md'}
|
||||
disabled={!query}
|
||||
onClick={createAlert}
|
||||
prefix={<ConciergeBell size={16} />}
|
||||
aria-label="Create an alert"
|
||||
data-testid="explorer-create-alert"
|
||||
>
|
||||
{!isOneChartPerQuery && 'Create an alert'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return isOneChartPerQuery ? (
|
||||
<TooltipSimple title="Create an alert">{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAlertButton;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddToDashboardButton from './AddToDashboardButton';
|
||||
import CreateAlertButton from './CreateAlertButton';
|
||||
|
||||
function ExplorerActions({
|
||||
query,
|
||||
dashboardQuery = query,
|
||||
sourcepage,
|
||||
panelType,
|
||||
isOneChartPerQuery,
|
||||
}: {
|
||||
query: Query | null;
|
||||
// When the dashboard export differs from the alert one (traces list injects columns).
|
||||
dashboardQuery?: Query | null;
|
||||
sourcepage: DataSource;
|
||||
panelType?: PANEL_TYPES;
|
||||
isOneChartPerQuery?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<CreateAlertButton
|
||||
query={query}
|
||||
sourcepage={sourcepage}
|
||||
isOneChartPerQuery={isOneChartPerQuery}
|
||||
/>
|
||||
<AddToDashboardButton
|
||||
query={dashboardQuery}
|
||||
sourcepage={sourcepage}
|
||||
panelType={panelType}
|
||||
isOneChartPerQuery={isOneChartPerQuery}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExplorerActions;
|
||||
@@ -1,320 +0,0 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getExportQueryData as getLogsExportQuery,
|
||||
getQueryByPanelType as getLogsQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
getExportQueryData as getTracesExportQuery,
|
||||
getQueryByPanelType as getTracesQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import AddToDashboardButton from '../AddToDashboardButton';
|
||||
import { getExportPanelType } from '../utils';
|
||||
|
||||
const DASHBOARD = { id: 'dash-1', title: 'Dash 1' };
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: jest.fn(),
|
||||
}));
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'widget-1' }));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// The picker is the dialog's business; here it just hands a dashboard back.
|
||||
jest.mock('container/ExportPanel/ExportPanelContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
open,
|
||||
query,
|
||||
onExport,
|
||||
}: {
|
||||
open: boolean;
|
||||
query: Query | null;
|
||||
onExport: (dashboard: { id: string; title: string }) => void;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="export-stub"
|
||||
data-query={JSON.stringify(query)}
|
||||
onClick={(): void => onExport({ id: 'dash-1', title: 'Dash 1' })}
|
||||
>
|
||||
export
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedUseSafeNavigate = jest.mocked(useSafeNavigate);
|
||||
const mockedLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
const COLUMNS = [{ name: 'service.name' }, { name: 'name' }];
|
||||
const options = { selectColumns: COLUMNS } as unknown as OptionsQuery;
|
||||
|
||||
function stagedQuery(dataSource: DataSource, queryName = 'A'): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
id: `query-${queryName}`,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
queryName,
|
||||
aggregateOperator: StringOperators.COUNT,
|
||||
filter: { expression: FILTER },
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function setPanelType(panelType: PANEL_TYPES): void {
|
||||
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>);
|
||||
}
|
||||
|
||||
async function exportTo(
|
||||
query: Query | null,
|
||||
sourcepage: DataSource,
|
||||
panelType: PANEL_TYPES,
|
||||
panelTypeProp?: PANEL_TYPES,
|
||||
): Promise<void> {
|
||||
setPanelType(panelType);
|
||||
render(
|
||||
<AddToDashboardButton
|
||||
query={query}
|
||||
sourcepage={sourcepage}
|
||||
panelType={panelTypeProp}
|
||||
/>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('explorer-add-to-dashboard'));
|
||||
await user.click(screen.getByTestId('export-stub'));
|
||||
}
|
||||
|
||||
function expectedLink(query: Query, panelType: PANEL_TYPES): string | null {
|
||||
return buildExportPanelLink({
|
||||
query,
|
||||
panelType,
|
||||
dashboardId: DASHBOARD.id,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AddToDashboardButton', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockReset();
|
||||
mockedLogEvent.mockClear();
|
||||
mockedUseSafeNavigate.mockReturnValue({ safeNavigate: mockSafeNavigate });
|
||||
});
|
||||
|
||||
it('is disabled without a query and the picker stays closed', () => {
|
||||
setPanelType(PANEL_TYPES.LIST);
|
||||
render(<AddToDashboardButton query={null} sourcepage={DataSource.LOGS} />);
|
||||
|
||||
expect(screen.getByTestId('explorer-add-to-dashboard')).toBeDisabled();
|
||||
expect(screen.queryByTestId('export-stub')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the picker the same query it will export', async () => {
|
||||
const query = stagedQuery(DataSource.LOGS);
|
||||
setPanelType(PANEL_TYPES.TIME_SERIES);
|
||||
render(<AddToDashboardButton query={query} sourcepage={DataSource.LOGS} />);
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId('explorer-add-to-dashboard'));
|
||||
|
||||
expect(screen.getByTestId('export-stub')).toHaveAttribute(
|
||||
'data-query',
|
||||
JSON.stringify(query),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs the explorer's own open and success events", async () => {
|
||||
const query = stagedQuery(DataSource.TRACES);
|
||||
|
||||
await exportTo(query, DataSource.TRACES, PANEL_TYPES.TABLE);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
'Traces Explorer: Add to dashboard clicked',
|
||||
{
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
'Traces Explorer: Add to dashboard successful',
|
||||
{
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
isNewDashboard: undefined,
|
||||
dashboardName: DASHBOARD.title,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('a panel type from the page wins over the fold of the context one', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS);
|
||||
|
||||
// context says list, the page says time series
|
||||
await exportTo(
|
||||
query,
|
||||
DataSource.METRICS,
|
||||
PANEL_TYPES.LIST,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(query, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
|
||||
describe('logs, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.LOGS);
|
||||
|
||||
it('list: the list request shaping with timestamp desc, panel type list', async () => {
|
||||
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: FILTER },
|
||||
});
|
||||
const exportQuery = getLogsExportQuery(
|
||||
listRequest,
|
||||
PANEL_TYPES.LIST,
|
||||
) as Query;
|
||||
|
||||
await exportTo(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.LIST),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query untouched, same panel type',
|
||||
async (panelType) => {
|
||||
const exportQuery = getLogsExportQuery(staged, panelType) as Query;
|
||||
|
||||
await exportTo(exportQuery, DataSource.LOGS, panelType);
|
||||
|
||||
expect(exportQuery).toBe(staged);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(staged, panelType),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('traces, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.TRACES);
|
||||
|
||||
it('list: list shaping plus the selected columns, panel type list', async () => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, PANEL_TYPES.LIST),
|
||||
getExportPanelType(PANEL_TYPES.LIST),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.LIST);
|
||||
|
||||
const [queryData] = exportQuery.builder.queryData;
|
||||
expect(queryData.selectColumns).toStrictEqual(COLUMNS);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.LIST),
|
||||
);
|
||||
});
|
||||
|
||||
it('trace: list shaping, no columns, panel type folds to time series', async () => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, PANEL_TYPES.TRACE),
|
||||
getExportPanelType(PANEL_TYPES.TRACE),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.TRACE);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].selectColumns).toBeUndefined();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
|
||||
// Same as the alert: the list / trace order lives in ListView state and the
|
||||
// page shapes the export without it, so the panel query has no order by.
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: order by is not carried into the panel query',
|
||||
async (panelType) => {
|
||||
expect(staged.builder.queryData[0].orderBy).toHaveLength(1);
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, panelType),
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, getExportPanelType(panelType)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query untouched, same panel type',
|
||||
async (panelType) => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, panelType),
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(exportQuery).toBe(staged);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(staged, panelType),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('metrics: the chart query as is, panel type time series from the page', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS);
|
||||
|
||||
await exportTo(
|
||||
query,
|
||||
DataSource.METRICS,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(query, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
getExportQueryData as getLogsExportQuery,
|
||||
getQueryByPanelType as getLogsQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import CreateAlertButton from '../CreateAlertButton';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const mockPush = jest.fn();
|
||||
const mockedUseHistory = jest.mocked(useHistory);
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
const ORDER_BY = [{ columnName: 'timestamp', order: 'asc' }];
|
||||
|
||||
function stagedQuery(
|
||||
dataSource: DataSource,
|
||||
aggregateOperator: StringOperators,
|
||||
queryName = 'A',
|
||||
): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
id: `query-${queryName}`,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
queryName,
|
||||
aggregateOperator,
|
||||
filter: { expression: FILTER },
|
||||
orderBy: ORDER_BY,
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function pushedQuery(): Query {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
|
||||
expect(path).toBe(ROUTES.ALERTS_NEW);
|
||||
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
|
||||
return JSON.parse(raw as string);
|
||||
}
|
||||
|
||||
function setPanelType(panelType: PANEL_TYPES): void {
|
||||
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>);
|
||||
}
|
||||
|
||||
async function clickCreateAlert(
|
||||
query: Query | null,
|
||||
sourcepage: DataSource,
|
||||
panelType: PANEL_TYPES,
|
||||
): Promise<void> {
|
||||
setPanelType(panelType);
|
||||
render(<CreateAlertButton query={query} sourcepage={sourcepage} />);
|
||||
await userEvent.setup().click(screen.getByTestId('explorer-create-alert'));
|
||||
}
|
||||
|
||||
describe('CreateAlertButton', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset();
|
||||
mockedLogEvent.mockClear();
|
||||
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
|
||||
typeof useHistory
|
||||
>);
|
||||
});
|
||||
|
||||
it('is disabled and does nothing without a query', async () => {
|
||||
await clickCreateAlert(null, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
expect(screen.getByTestId('explorer-create-alert')).toBeDisabled();
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs the explorer's own create alert event", async () => {
|
||||
const query = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
|
||||
|
||||
await clickCreateAlert(query, DataSource.TRACES, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith('Traces Explorer: Create alert', {
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('one chart per query on metrics: icon only, and the event carries the layout', async () => {
|
||||
setPanelType(PANEL_TYPES.TIME_SERIES);
|
||||
render(
|
||||
<CreateAlertButton
|
||||
query={stagedQuery(DataSource.METRICS, StringOperators.COUNT)}
|
||||
sourcepage={DataSource.METRICS}
|
||||
isOneChartPerQuery
|
||||
/>,
|
||||
);
|
||||
const button = screen.getByTestId('explorer-create-alert');
|
||||
expect(button).not.toHaveTextContent('Create an alert');
|
||||
|
||||
await userEvent.setup().click(button);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
'Metrics Explorer: Add to alert clicked',
|
||||
{
|
||||
sourcepage: DataSource.METRICS,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
tab: 'explorer',
|
||||
oneChartPerQueryEnabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('logs, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.LOGS, StringOperators.NOOP);
|
||||
|
||||
it('list: count aggregation, no order by, filter and pagination as the page sent them', async () => {
|
||||
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: FILTER },
|
||||
});
|
||||
const exportQuery = getLogsExportQuery(
|
||||
listRequest,
|
||||
PANEL_TYPES.LIST,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.filter).toStrictEqual({ expression: FILTER });
|
||||
expect(queryData.pageSize).toBe(100);
|
||||
});
|
||||
|
||||
it('time series: staged query as is, order by and group by kept', async () => {
|
||||
const tsStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
|
||||
const exportQuery = getLogsExportQuery(
|
||||
tsStaged,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(
|
||||
exportQuery,
|
||||
DataSource.LOGS,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData).toStrictEqual(tsStaged.builder.queryData[0]);
|
||||
});
|
||||
|
||||
it('table: staged query as is', async () => {
|
||||
const tableStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
|
||||
const exportQuery = getLogsExportQuery(
|
||||
tableStaged,
|
||||
PANEL_TYPES.TABLE,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.TABLE);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(tableStaged.builder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traces, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.TRACES, StringOperators.NOOP);
|
||||
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: count aggregation, group by cleared by the list shaping, filter kept',
|
||||
async (panelType) => {
|
||||
const exportQuery = getTracesQueryByPanelType(staged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.filter).toStrictEqual({ expression: FILTER });
|
||||
},
|
||||
);
|
||||
|
||||
// The list / trace views keep their order in ListView state, and the page
|
||||
// shapes the export without it, so the alert never sees an order by.
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: order by is not carried, even when the staged query has one',
|
||||
async (panelType) => {
|
||||
expect(staged.builder.queryData[0].orderBy).toStrictEqual(ORDER_BY);
|
||||
const exportQuery = getTracesQueryByPanelType(staged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(pushedQuery().builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query as is',
|
||||
async (panelType) => {
|
||||
const aggStaged = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
|
||||
const exportQuery = getTracesQueryByPanelType(aggStaged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(aggStaged.builder);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('metrics: the chart query as is', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS, StringOperators.COUNT);
|
||||
|
||||
await clickCreateAlert(query, DataSource.METRICS, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(query.builder);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { getCreateAlertLink, getExportPanelType } from '../utils';
|
||||
|
||||
function withFirstQuery(
|
||||
base: Query,
|
||||
overrides: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query {
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], ...overrides }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQuery(link: string): Query {
|
||||
const search = link.split('?')[1];
|
||||
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
|
||||
return JSON.parse(raw as string);
|
||||
}
|
||||
|
||||
describe('getExportPanelType', () => {
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, PANEL_TYPES.LIST])(
|
||||
'keeps %s',
|
||||
(panelType) => {
|
||||
expect(getExportPanelType(panelType)).toBe(panelType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.BAR, PANEL_TYPES.PIE, PANEL_TYPES.TRACE, null])(
|
||||
'folds %s to time series',
|
||||
(panelType) => {
|
||||
expect(getExportPanelType(panelType)).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('getCreateAlertLink', () => {
|
||||
const orderBy = [{ columnName: 'timestamp', order: 'desc' }];
|
||||
|
||||
it('points at the new alert route with the query in the url', () => {
|
||||
const query = initialQueriesMap.traces;
|
||||
const link = getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
expect(link.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
|
||||
expect(decodeQuery(link)).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('logs list: noop becomes count and order by is dropped', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).builder.queryData;
|
||||
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('logs time series keeps order by', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.COUNT,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
).builder.queryData;
|
||||
|
||||
expect(queryData.orderBy).toStrictEqual(orderBy);
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s drops order by whatever the source',
|
||||
(panelType) => {
|
||||
const query = withFirstQuery(initialQueriesMap.traces, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(getCreateAlertLink({ query, panelType }))
|
||||
.builder.queryData;
|
||||
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('converts a noop on any query, not only the first', () => {
|
||||
const first = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query: Query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{ ...first, aggregateOperator: StringOperators.COUNT },
|
||||
{ ...first, queryName: 'B', aggregateOperator: StringOperators.NOOP },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const operators = decodeQuery(
|
||||
getCreateAlertLink({ query, panelType: PANEL_TYPES.TIME_SERIES }),
|
||||
).builder.queryData.map((item) => item.aggregateOperator);
|
||||
|
||||
expect(operators).toStrictEqual([
|
||||
StringOperators.COUNT,
|
||||
StringOperators.COUNT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mutate the query it is given', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
const snapshot = JSON.stringify(query);
|
||||
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(query)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
MetricsExplorerEventKeys,
|
||||
MetricsExplorerEvents,
|
||||
} from 'container/MetricsExplorer/events';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export const EXPLORER_ACTION_EVENTS: Record<
|
||||
DataSource,
|
||||
{ createAlert: string; addToDashboard: string; exported: string }
|
||||
> = {
|
||||
[DataSource.LOGS]: {
|
||||
createAlert: 'Logs Explorer: Create alert',
|
||||
addToDashboard: 'Logs Explorer: Add to dashboard clicked',
|
||||
exported: 'Logs Explorer: Add to dashboard successful',
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
createAlert: 'Traces Explorer: Create alert',
|
||||
addToDashboard: 'Traces Explorer: Add to dashboard clicked',
|
||||
exported: 'Traces Explorer: Add to dashboard successful',
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
createAlert: MetricsExplorerEvents.AddToAlertClicked,
|
||||
addToDashboard: MetricsExplorerEvents.AddToDashboardClicked,
|
||||
exported: MetricsExplorerEvents.AddToDashboardSuccessful,
|
||||
},
|
||||
};
|
||||
|
||||
export function getExplorerActionEventPayload({
|
||||
sourcepage,
|
||||
panelType,
|
||||
isOneChartPerQuery,
|
||||
}: {
|
||||
sourcepage: DataSource;
|
||||
panelType: PANEL_TYPES | null;
|
||||
isOneChartPerQuery: boolean;
|
||||
}): Record<string, unknown> {
|
||||
if (sourcepage !== DataSource.METRICS) {
|
||||
return { sourcepage, panelType };
|
||||
}
|
||||
return {
|
||||
sourcepage,
|
||||
panelType,
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExportPanelType(panelType: PANEL_TYPES | null): PANEL_TYPES {
|
||||
return panelType && AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
}
|
||||
|
||||
// Alerts need an aggregation, and list style views carry an order the alert
|
||||
// cannot use.
|
||||
export function getCreateAlertLink({
|
||||
query,
|
||||
panelType,
|
||||
}: {
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES | null;
|
||||
}): string {
|
||||
const isListStyle =
|
||||
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
|
||||
|
||||
const alertQuery = cloneDeep(query);
|
||||
alertQuery.builder.queryData = alertQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
aggregateOperator:
|
||||
item.aggregateOperator === StringOperators.NOOP
|
||||
? StringOperators.COUNT
|
||||
: item.aggregateOperator,
|
||||
orderBy: isListStyle ? [] : item.orderBy,
|
||||
}));
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
JSON.stringify(alertQuery),
|
||||
)}`;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
|
||||
import styles from './EventsContent.module.scss';
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ const accountsResponse: ListAccounts200 = {
|
||||
agentReport: {
|
||||
timestampMillis: 1747114366214,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
providerAccountId: PROVIDER_ACCOUNT_ID,
|
||||
removedAt: null,
|
||||
|
||||
64
frontend/src/container/LogDetailedView/ActionItem.tsx
Normal file
64
frontend/src/container/LogDetailedView/ActionItem.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { CircleMinus, CirclePlus } from '@signozhq/icons';
|
||||
import { Button, Col, Popover } from 'antd';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
function ActionItem({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
onClickActionItem,
|
||||
}: ActionItemProps): JSX.Element {
|
||||
const handleClick = useCallback(
|
||||
(operator: string) => {
|
||||
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
|
||||
|
||||
onClickActionItem(fieldKey, validatedFieldValue, operator);
|
||||
},
|
||||
[onClickActionItem, fieldKey, fieldValue],
|
||||
);
|
||||
|
||||
const onClickHandler = useCallback(
|
||||
(operator: string) => (): void => {
|
||||
handleClick(operator);
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const PopOverMenuContent = useMemo(
|
||||
() => (
|
||||
<Col>
|
||||
<Button type="text" size="small" onClick={onClickHandler(OPERATORS.IN)}>
|
||||
<CirclePlus size={12} /> Filter for value
|
||||
</Button>
|
||||
<br />
|
||||
<Button type="text" size="small" onClick={onClickHandler(OPERATORS.NIN)}>
|
||||
<CircleMinus size={12} /> Filter out value
|
||||
</Button>
|
||||
</Col>
|
||||
),
|
||||
[onClickHandler],
|
||||
);
|
||||
return (
|
||||
<Popover placement="bottomLeft" content={PopOverMenuContent} trigger="click">
|
||||
<Button type="text" size="small">
|
||||
...
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default memo(ActionItem);
|
||||
@@ -0,0 +1,14 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TitleWrapper = styled.span`
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
|
||||
.hover-reveal {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover .hover-reveal {
|
||||
visibility: visible;
|
||||
}
|
||||
`;
|
||||
247
frontend/src/container/LogDetailedView/BodyTitleRenderer.tsx
Normal file
247
frontend/src/container/LogDetailedView/BodyTitleRenderer.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import {
|
||||
type BaseMenuItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
negateOperator,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { TitleWrapper } from './BodyTitleRenderer.styles';
|
||||
import { DROPDOWN_KEY } from './constant';
|
||||
import { BodyTitleRendererProps } from './LogDetailedView.types';
|
||||
import {
|
||||
generateFieldKeyForArray,
|
||||
getDataTypes,
|
||||
removeObjectFromString,
|
||||
} from './utils';
|
||||
|
||||
function BodyTitleRenderer({
|
||||
title,
|
||||
parentIsArray = false,
|
||||
nodeKey,
|
||||
value,
|
||||
handleChangeSelectedView,
|
||||
}: BodyTitleRendererProps): JSX.Element {
|
||||
const { onAddToQuery } = useActiveLog();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const cleanedNodeKey = removeObjectFromString(nodeKey);
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
// Group by is supported only for body json query enabled and not for array elements
|
||||
const isGroupBySupported =
|
||||
isBodyJsonQueryEnabled && !cleanedNodeKey.includes('[]');
|
||||
|
||||
const filterHandler = (isFilterIn: boolean) => (): void => {
|
||||
if (parentIsArray) {
|
||||
onAddToQuery(
|
||||
generateFieldKeyForArray(
|
||||
cleanedNodeKey,
|
||||
getDataTypes(value),
|
||||
isBodyJsonQueryEnabled,
|
||||
),
|
||||
`${value}`,
|
||||
isFilterIn
|
||||
? QUERY_BUILDER_FUNCTIONS.HAS
|
||||
: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
|
||||
parentIsArray ? getDataTypes([value]) : getDataTypes(value),
|
||||
);
|
||||
} else {
|
||||
onAddToQuery(
|
||||
`body.${cleanedNodeKey}`,
|
||||
`${value}`,
|
||||
isFilterIn ? OPERATORS['='] : OPERATORS['!='],
|
||||
getDataTypes(value),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const groupByHandler = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupByKey = parentIsArray
|
||||
? generateFieldKeyForArray(
|
||||
cleanedNodeKey,
|
||||
getDataTypes(value),
|
||||
isBodyJsonQueryEnabled,
|
||||
)
|
||||
: `body.${cleanedNodeKey}`;
|
||||
|
||||
const fieldDataType = getDataTypes(value);
|
||||
const normalizedDataType: DataTypes | undefined = Object.values(
|
||||
DataTypes,
|
||||
).includes(fieldDataType as DataTypes)
|
||||
? (fieldDataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
if (index === 0) {
|
||||
const newGroupByItem: BaseAutocompleteData = {
|
||||
key: groupByKey,
|
||||
type: '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
}, [
|
||||
cleanedNodeKey,
|
||||
handleChangeSelectedView,
|
||||
isBodyJsonQueryEnabled,
|
||||
parentIsArray,
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
value,
|
||||
]);
|
||||
|
||||
const onClickHandler = (key: string): void => {
|
||||
const mapper = {
|
||||
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
|
||||
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
|
||||
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
|
||||
};
|
||||
|
||||
const handler = mapper[key];
|
||||
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: BaseMenuItem[] = [
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_IN,
|
||||
label: `Filter for ${value}`,
|
||||
},
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
// Prevent tree node expansion/collapse
|
||||
e.stopPropagation();
|
||||
let copyText: string;
|
||||
|
||||
// Check if value is an object or array
|
||||
const isObject = typeof value === 'object' && value !== null;
|
||||
|
||||
if (isObject) {
|
||||
// For objects/arrays, stringify the entire structure
|
||||
copyText = JSON.stringify(value, null, 2);
|
||||
} else if (parentIsArray) {
|
||||
// array elements
|
||||
copyText = `${value}`;
|
||||
} else {
|
||||
// primitive values
|
||||
const valueStr = typeof value === 'string' ? value : String(value);
|
||||
copyText = valueStr;
|
||||
}
|
||||
|
||||
setCopy(copyText);
|
||||
|
||||
if (copyText) {
|
||||
const notificationMessage = isObject
|
||||
? `${cleanedNodeKey} object copied to clipboard`
|
||||
: `${cleanedNodeKey} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
[cleanedNodeKey, parentIsArray, setCopy, value, notifications],
|
||||
);
|
||||
|
||||
return (
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<span
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<div data-log-detail-ignore="true">
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.key}
|
||||
onSelect={(): void => onClickHandler(item.key as string)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
<span>
|
||||
: <span style={{ color: orange[6] }}>{`${value}`}</span>
|
||||
</span>
|
||||
)}
|
||||
</TitleWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default BodyTitleRenderer;
|
||||
92
frontend/src/container/LogDetailedView/JsonView.tsx
Normal file
92
frontend/src/container/LogDetailedView/JsonView.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { JSONViewProps } from './LogDetailedView.types';
|
||||
import { aggregateAttributesResourcesToString } from './utils';
|
||||
|
||||
import './JsonView.styles.scss';
|
||||
|
||||
function JSONView({ logData }: JSONViewProps): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
|
||||
const LogJsonData = useMemo(
|
||||
() => aggregateAttributesResourcesToString(logData),
|
||||
[logData],
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const options: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
wordWrap: isWrapWord ? 'on' : 'off',
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
fontWeight: '400',
|
||||
// fontFamily: 'SF Mono',
|
||||
fontFamily: 'Geist Mono',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
},
|
||||
folding: false,
|
||||
};
|
||||
|
||||
const handleWrapWord = (checked: boolean): void => {
|
||||
setIsWrapWord(checked);
|
||||
};
|
||||
|
||||
function setEditorTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': Color.BG_INK_400,
|
||||
},
|
||||
// fontFamily: 'SF Mono',
|
||||
fontFamily: 'Space Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="json-view-container">
|
||||
<MEditor
|
||||
value={LogJsonData}
|
||||
language="json"
|
||||
options={options}
|
||||
onChange={(): void => {}}
|
||||
height="68vh"
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
|
||||
<div className="json-view-footer">
|
||||
<div className="log-switch">
|
||||
<div className="wrap-word-switch">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch value={isWrapWord} onChange={handleWrapWord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JSONView;
|
||||
@@ -1,5 +1,14 @@
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export interface BodyTitleRendererProps {
|
||||
title: string;
|
||||
nodeKey: string;
|
||||
value: unknown;
|
||||
parentIsArray?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
export type AnyObject = { [key: string]: any };
|
||||
|
||||
@@ -13,21 +22,6 @@ export interface IFieldAttributes {
|
||||
logType?: MetricsType;
|
||||
}
|
||||
|
||||
/** One key/field/value row in an attribute table. */
|
||||
export interface DataType {
|
||||
key: string;
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
export interface JSONViewProps {
|
||||
logData: ILog;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Collapse } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
|
||||
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
|
||||
import TableView from './TableView';
|
||||
import {
|
||||
aggregateAttributesResourcesToObject,
|
||||
buildPrettyViewData,
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
removeEscapeCharacters,
|
||||
} from './utils';
|
||||
|
||||
import './Overview.styles.scss';
|
||||
@@ -19,58 +38,247 @@ const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
|
||||
interface OverviewProps {
|
||||
logData: ILog;
|
||||
isListViewPanel?: boolean;
|
||||
selectedOptions: OptionsQuery;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
type Props = OverviewProps &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
|
||||
function Overview({
|
||||
logData,
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
isListViewPanel = false,
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
}: OverviewProps): JSX.Element {
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
const [isAttributesExpanded, setIsAttributesExpanded] =
|
||||
useState<boolean>(true);
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { actions, visibleActions } = useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel,
|
||||
onApplyLogFilter,
|
||||
});
|
||||
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
if (isLogDetailsV2) {
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
return (
|
||||
<div className="overview-container">
|
||||
<DataViewer
|
||||
data={prettyData}
|
||||
drawerKey="logs-details"
|
||||
fontSize={13}
|
||||
prettyViewProps={{
|
||||
actions,
|
||||
visibleActions,
|
||||
renderLeafValue: (value, keyPath): ReactNode | undefined => {
|
||||
// Sanitize (unescape + ANSI→color) string values under `body`.
|
||||
// Skip huge ones (render raw, still safe) to avoid the sanitize
|
||||
// choke;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
keyPath[keyPath.length - 1] !== 'body' ||
|
||||
value.length > MAX_BODY_SANITIZE_CHARS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="log-body-value"
|
||||
// Safe: getSanitizedLogBody runs the value through dompurify.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
jsonString={JSON.stringify(raw, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const options: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
wordWrap: isWrapWord ? 'on' : 'off',
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
fontWeight: '400',
|
||||
fontFamily: 'Geist Mono',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
},
|
||||
};
|
||||
|
||||
const handleWrapWord = (checked: boolean): void => {
|
||||
setIsWrapWord(checked);
|
||||
};
|
||||
|
||||
function setEditorTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme('my-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': Color.BG_INK_400,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleSearchVisible = (): void => {
|
||||
setIsSearchVisible(!isSearchVisible);
|
||||
};
|
||||
|
||||
const toogleAttributePanelOpenState = (): void => {
|
||||
setIsAttributesExpanded(!isAttributesExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overview-container">
|
||||
<DataViewer
|
||||
data={prettyData}
|
||||
drawerKey="logs-details"
|
||||
fontSize={13}
|
||||
prettyViewProps={{
|
||||
actions,
|
||||
visibleActions,
|
||||
renderLeafValue: (value, keyPath): ReactNode | undefined => {
|
||||
// Sanitize (unescape + ANSI→color) string values under `body`.
|
||||
// Skip huge ones (render raw, still safe) to avoid the sanitize
|
||||
// choke;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
keyPath[keyPath.length - 1] !== 'body' ||
|
||||
value.length > MAX_BODY_SANITIZE_CHARS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="log-body-value"
|
||||
// Safe: getSanitizedLogBody runs the value through dompurify.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
<Collapse
|
||||
defaultActiveKey={['1']}
|
||||
expandIcon={(props): ReactNode =>
|
||||
props.isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Badge color="vanilla">
|
||||
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
|
||||
body
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
),
|
||||
children: (
|
||||
<div className="logs-body-content">
|
||||
<MEditor
|
||||
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
|
||||
language="json"
|
||||
options={options}
|
||||
onChange={(): void => {}}
|
||||
height="20vh"
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
onMount={(_, monaco): void => {
|
||||
document.fonts.ready.then(() => {
|
||||
monaco.editor.remeasureFonts();
|
||||
});
|
||||
}}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
<Divider
|
||||
style={{
|
||||
margin: 0,
|
||||
border: isDarkMode
|
||||
? `1px solid ${Color.BG_SLATE_500}`
|
||||
: `1px solid ${Color.BG_VANILLA_200}`,
|
||||
}}
|
||||
/>
|
||||
<div className="log-switch">
|
||||
<div className="wrap-word-switch">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch value={isWrapWord} onChange={handleWrapWord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
// extra: <Badge className="tag" color="vanilla">JSON</Badge>,
|
||||
className: 'collapse-content',
|
||||
},
|
||||
}}
|
||||
jsonString={JSON.stringify(raw, null, 2)}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
className="attribute-table"
|
||||
defaultActiveKey={['1']}
|
||||
bordered={false}
|
||||
expandIcon={(props): ReactNode =>
|
||||
props.isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div
|
||||
className="attribute-tab-header"
|
||||
onClick={toogleAttributePanelOpenState}
|
||||
>
|
||||
<Badge color="vanilla">
|
||||
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
|
||||
Attributes
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
|
||||
{isAttributesExpanded && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="none"
|
||||
className="action-btn"
|
||||
prefix={<Search size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSearchVisible();
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{isSearchVisible && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for a field..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TableView
|
||||
logData={logData}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fieldSearchInput={fieldSearchInput}
|
||||
onClickActionItem={onClickActionItem}
|
||||
isListViewPanel={isListViewPanel}
|
||||
selectedOptions={selectedOptions}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
className: 'collapse-content attribute-collapse',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -78,6 +286,7 @@ function Overview({
|
||||
|
||||
Overview.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
listViewPanelSelectedFields: null,
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
|
||||
|
||||
93
frontend/src/container/LogDetailedView/TableView.styles.scss
Normal file
93
frontend/src/container/LogDetailedView/TableView.styles.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
.attribute-table-container {
|
||||
.ant-table {
|
||||
background: var(--l2-background);
|
||||
|
||||
.ant-table-row:hover {
|
||||
.ant-table-cell {
|
||||
.value-field {
|
||||
.action-btn {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 16px;
|
||||
transform: translateY(-50%);
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attribute-name {
|
||||
.ant-btn {
|
||||
&:hover {
|
||||
background-color: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-pin {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 14px 8px 8px;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
|
||||
.log-attribute-pin {
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.pin-attribute-icon {
|
||||
border: none;
|
||||
|
||||
&.pinned svg {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value-field-container {
|
||||
background: var(--l2-background);
|
||||
|
||||
&.attribute-pin {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.value-field {
|
||||
font-family: 'Geist Mono';
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: none;
|
||||
width: max-content;
|
||||
position: absolute;
|
||||
padding: 0 16px;
|
||||
right: 0;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
padding: 2px 3px;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
349
frontend/src/container/LogDetailedView/TableView.tsx
Normal file
349
frontend/src/container/LogDetailedView/TableView.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Link, Pin } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Space, TableColumnsType as ColumnsType, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import AddToQueryHOC, {
|
||||
AddToQueryHOCProps,
|
||||
} from 'components/Logs/AddToQueryHOC';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
|
||||
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from './config';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
import TableViewActions from './TableView/TableViewActions';
|
||||
import {
|
||||
filterKeyForField,
|
||||
findKeyPath,
|
||||
flattenObject,
|
||||
getFieldAttributes,
|
||||
} from './utils';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
interface TableViewProps {
|
||||
logData: ILog;
|
||||
fieldSearchInput: string;
|
||||
selectedOptions: OptionsQuery;
|
||||
isListViewPanel?: boolean;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
type Props = TableViewProps &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
|
||||
function TableView({
|
||||
logData,
|
||||
fieldSearchInput,
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
isListViewPanel = false,
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element | null {
|
||||
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
const [isfilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [pinnedAttributes, setPinnedAttributes] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const pinnedAttributes: Record<string, boolean> = {};
|
||||
|
||||
if (isListViewPanel) {
|
||||
listViewPanelSelectedFields?.forEach((val) => {
|
||||
const path = findKeyPath(logData, val.name, '');
|
||||
if (path) {
|
||||
pinnedAttributes[path] = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
selectedOptions.selectColumns.forEach((val) => {
|
||||
const path = findKeyPath(logData, val.name, '');
|
||||
if (path) {
|
||||
pinnedAttributes[path] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// pin trace_id by default when present
|
||||
if (logData?.trace_id) {
|
||||
pinnedAttributes.trace_id = true;
|
||||
}
|
||||
|
||||
setPinnedAttributes(pinnedAttributes);
|
||||
}, [
|
||||
logData,
|
||||
selectedOptions.selectColumns,
|
||||
listViewPanelSelectedFields,
|
||||
isListViewPanel,
|
||||
]);
|
||||
|
||||
// When USE_JSON_BODY is enabled, body arrives as a pre-parsed object. Serialize it
|
||||
// back to a string so flattenObject keeps `body` as a single table row instead of
|
||||
// recursively expanding it into dotted sub-keys (body.message, body.foo.bar, …),
|
||||
// which would break the tree view in BodyContent that relies on record.field === 'body'.
|
||||
const flattenLogData: Record<string, string> | null = useMemo(() => {
|
||||
if (!logData) {
|
||||
return null;
|
||||
}
|
||||
const normalizedLog =
|
||||
typeof logData.body === 'object' && logData.body !== null
|
||||
? { ...logData, body: JSON.stringify(logData.body) }
|
||||
: logData;
|
||||
return flattenObject(normalizedLog);
|
||||
}, [logData]);
|
||||
|
||||
const handleClick = (
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
dataType: string | undefined,
|
||||
fieldType: string | undefined,
|
||||
): void => {
|
||||
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
|
||||
if (onClickActionItem) {
|
||||
onClickActionItem(
|
||||
fieldKey,
|
||||
validatedFieldValue,
|
||||
operator,
|
||||
dataType as DataTypes,
|
||||
fieldType,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler =
|
||||
(
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
dataType: string | undefined,
|
||||
fieldType: MetricsType | undefined,
|
||||
) =>
|
||||
(): void => {
|
||||
handleClick(operator, fieldKey, fieldValue, dataType, fieldType);
|
||||
if (operator === OPERATORS['=']) {
|
||||
setIsFilterInLoading(true);
|
||||
}
|
||||
if (operator === OPERATORS['!=']) {
|
||||
setIsFilterOutLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (logData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataSource =
|
||||
flattenLogData !== null &&
|
||||
Object.keys(flattenLogData)
|
||||
.filter((field) => fieldSearchFilter(field, fieldSearchInput))
|
||||
.map((key) => ({
|
||||
key,
|
||||
field: key,
|
||||
value: JSON.stringify(flattenLogData[key]),
|
||||
}));
|
||||
|
||||
const onTraceHandler = (
|
||||
record: DataType,
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
): void => {
|
||||
if (flattenLogData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = flattenLogData[record.field];
|
||||
|
||||
const spanId = flattenLogData?.span_id;
|
||||
|
||||
if (traceId) {
|
||||
const basePath = generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceId,
|
||||
});
|
||||
|
||||
const route = spanId ? `${basePath}?spanId=${spanId}` : basePath;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// open the trace in new tab
|
||||
openInNewTab(route);
|
||||
} else {
|
||||
history.push(route);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!dataSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'pin',
|
||||
key: 'pin',
|
||||
width: 5,
|
||||
align: 'left',
|
||||
className: 'attribute-pin value-field-container',
|
||||
render: (fieldData: Record<string, string>, record): JSX.Element => {
|
||||
let pinColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500;
|
||||
|
||||
if (pinnedAttributes[record?.key]) {
|
||||
pinColor = Color.BG_ROBIN_500;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="log-attribute-pin value-field">
|
||||
<div
|
||||
className={cx(
|
||||
'pin-attribute-icon',
|
||||
pinnedAttributes[record?.key] ? 'pinned' : '',
|
||||
)}
|
||||
>
|
||||
{pinnedAttributes[record?.key] && <Pin size={14} color={pinColor} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Field',
|
||||
dataIndex: 'field',
|
||||
key: 'field',
|
||||
width: 50,
|
||||
align: 'left',
|
||||
ellipsis: true,
|
||||
className: 'attribute-name',
|
||||
render: (field: string, record): JSX.Element => {
|
||||
const renderedField = <FieldRenderer field={field} />;
|
||||
|
||||
if (record.field === 'trace_id') {
|
||||
const traceId = flattenLogData[record.field];
|
||||
|
||||
return (
|
||||
<Space size="middle" className="log-attribute">
|
||||
<Typography.Text>{renderedField}</Typography.Text>
|
||||
|
||||
{traceId && (
|
||||
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
onClick={(
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
): void => {
|
||||
onTraceHandler(record, event);
|
||||
}}
|
||||
>
|
||||
<Link size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldFilterKey = filterKeyForField(field);
|
||||
const { dataType } = getFieldAttributes(field);
|
||||
if (!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey)) {
|
||||
return (
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldFilterKey}
|
||||
fieldValue={flattenLogData[field]}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={FontSize.SMALL}
|
||||
dataType={dataType as DataTypes}
|
||||
>
|
||||
{renderedField}
|
||||
</AddToQueryHOC>
|
||||
);
|
||||
}
|
||||
return renderedField;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
key: 'value',
|
||||
width: 70,
|
||||
ellipsis: false,
|
||||
className: 'value-field-container attribute-value',
|
||||
render: (fieldData: Record<string, string>, record): JSX.Element => (
|
||||
<TableViewActions
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isfilterInLoading={isfilterInLoading}
|
||||
isfilterOutLoading={isfilterOutLoading}
|
||||
onClickHandler={onClickHandler}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
function sortPinnedAttributes(
|
||||
data: Record<string, string>[],
|
||||
sortingObj: Record<string, boolean>,
|
||||
): Record<string, string>[] {
|
||||
const sortingKeys = Object.keys(sortingObj);
|
||||
return data.sort((a, b) => {
|
||||
const aKey = a.key;
|
||||
const bKey = b.key;
|
||||
const aSortIndex = sortingKeys.indexOf(aKey);
|
||||
const bSortIndex = sortingKeys.indexOf(bKey);
|
||||
|
||||
if (sortingObj[aKey] && !sortingObj[bKey]) {
|
||||
return -1;
|
||||
}
|
||||
if (!sortingObj[aKey] && sortingObj[bKey]) {
|
||||
return 1;
|
||||
}
|
||||
return aSortIndex - bSortIndex;
|
||||
});
|
||||
}
|
||||
|
||||
const sortedAttributes = sortPinnedAttributes(dataSource, pinnedAttributes);
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={sortedAttributes}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="attribute-table-container"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
listViewPanelSelectedFields: null,
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
|
||||
export interface DataType {
|
||||
key: string;
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default TableView;
|
||||
@@ -0,0 +1,62 @@
|
||||
.open-popover {
|
||||
&.value-field {
|
||||
.action-btn {
|
||||
display: flex !important;
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
right: 16px !important;
|
||||
transform: translateY(-50%) !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectable-tree {
|
||||
.ant-tree-node-content-wrapper {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-tree-title {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.table-view-actions-content {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0px;
|
||||
.more-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
padding: 12px 18px 12px 14px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.more-filter-actions:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Popover, Spin, Tooltip, Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowUpFromDot,
|
||||
Ellipsis,
|
||||
RefreshCw,
|
||||
} from '@signozhq/icons';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { RESTRICTED_SELECTED_FIELDS } from '../config';
|
||||
import { DataType } from '../TableView';
|
||||
import {
|
||||
filterKeyForField,
|
||||
getFieldAttributes,
|
||||
getSanitizedLogBody,
|
||||
parseFieldValue,
|
||||
removeEscapeCharacters,
|
||||
} from '../utils';
|
||||
import useAsyncJSONProcessing from './useAsyncJSONProcessing';
|
||||
|
||||
import './TableViewActions.styles.scss';
|
||||
|
||||
interface ITableViewActionsProps {
|
||||
fieldData: Record<string, string>;
|
||||
record: DataType;
|
||||
isListViewPanel: boolean;
|
||||
isfilterInLoading: boolean;
|
||||
isfilterOutLoading: boolean;
|
||||
onClickHandler: (
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
dataType: string | undefined,
|
||||
logType: MetricsType | undefined,
|
||||
) => () => void;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
// Memoized Tree Component
|
||||
const MemoizedTree = React.memo<{ treeData: DataNode[] }>(({ treeData }) => (
|
||||
<Tree
|
||||
defaultExpandAll
|
||||
showLine
|
||||
treeData={treeData}
|
||||
className="selectable-tree"
|
||||
/>
|
||||
));
|
||||
|
||||
MemoizedTree.displayName = 'MemoizedTree';
|
||||
|
||||
// Body Content Component
|
||||
const BodyContent: React.FC<{
|
||||
fieldData: Record<string, string>;
|
||||
record: DataType;
|
||||
bodyHtml: { __html: string };
|
||||
textToCopy: string;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}> = React.memo(
|
||||
({ fieldData, record, bodyHtml, textToCopy, handleChangeSelectedView }) => {
|
||||
const { isLoading, treeData, error } = useAsyncJSONProcessing(
|
||||
fieldData.value,
|
||||
record.field === 'body',
|
||||
handleChangeSelectedView,
|
||||
);
|
||||
|
||||
// Show JSON tree if available, otherwise show HTML content
|
||||
if (record.field === 'body' && treeData) {
|
||||
return <MemoizedTree treeData={treeData} />;
|
||||
}
|
||||
|
||||
if (record.field === 'body' && isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ color: Color.BG_SIENNA_400 }}>Processing JSON...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.field === 'body' && error) {
|
||||
return (
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
Error parsing Body JSON
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<CopyClipboardHOC entityKey="body" textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
</CopyClipboardHOC>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
BodyContent.displayName = 'BodyContent';
|
||||
|
||||
export default function TableViewActions(
|
||||
props: ITableViewActionsProps,
|
||||
): React.ReactElement {
|
||||
const {
|
||||
fieldData,
|
||||
record,
|
||||
isListViewPanel,
|
||||
isfilterInLoading,
|
||||
isfilterOutLoading,
|
||||
onClickHandler,
|
||||
handleChangeSelectedView,
|
||||
} = props;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
|
||||
|
||||
// there is no option for where clause in live logs page or infra monitoring
|
||||
const isLiveLogsOrInfraPage = useMemo(
|
||||
() =>
|
||||
pathname === ROUTES.LIVE_LOGS ||
|
||||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_HOSTS ||
|
||||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// Memoize bodyHtml computation
|
||||
const bodyHtml = useMemo(() => {
|
||||
if (record.field !== 'body') {
|
||||
return { __html: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
__html: getSanitizedLogBody(record.value, { shouldEscapeHtml: true }),
|
||||
};
|
||||
}, [record.field, record.value]);
|
||||
|
||||
const fieldFilterKey = filterKeyForField(fieldData.field);
|
||||
|
||||
const handleGroupByAttribute = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const normalizedDataType: DataTypes | undefined =
|
||||
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
|
||||
? (dataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
// Only add groupBy for index 0
|
||||
if (index === 0) {
|
||||
const newGroupByItem: BaseAutocompleteData = {
|
||||
key: fieldFilterKey,
|
||||
type: fieldType || '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
const updatedGroupBy = [...(item.groupBy || []), newGroupByItem];
|
||||
|
||||
return { ...item, groupBy: updatedGroupBy };
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
}, [
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
fieldFilterKey,
|
||||
fieldType,
|
||||
dataType,
|
||||
handleChangeSelectedView,
|
||||
]);
|
||||
|
||||
const handleReplaceFilter = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const normalizedDataType: DataTypes | undefined =
|
||||
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
|
||||
? (dataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
// Only replace filters for index 0
|
||||
if (index === 0) {
|
||||
const newFilterItem: BaseAutocompleteData = {
|
||||
key: fieldFilterKey,
|
||||
type: fieldType || '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
// Create new filter items array with single IN filter
|
||||
const newFilters = {
|
||||
items: [
|
||||
{
|
||||
id: '',
|
||||
key: newFilterItem,
|
||||
op: OPERATORS.IN,
|
||||
value: [parseFieldValue(fieldData.value)],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Clear the expression and update filters
|
||||
return {
|
||||
...item,
|
||||
filters: newFilters,
|
||||
filter: { expression: '' },
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
}, [
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
fieldFilterKey,
|
||||
fieldType,
|
||||
dataType,
|
||||
fieldData,
|
||||
handleChangeSelectedView,
|
||||
]);
|
||||
|
||||
// Memoize textToCopy computation
|
||||
const textToCopy = useMemo(() => {
|
||||
let text = fieldData.value;
|
||||
try {
|
||||
text = text.replace(/^"|"$/g, '');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to remove starting and ending quotes from the value',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// If the value is valid JSON (object or array), pretty-print it for copying
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch {
|
||||
// not JSON, return as-is
|
||||
}
|
||||
return text;
|
||||
}, [fieldData.value]);
|
||||
|
||||
// Memoize cleanTimestamp computation
|
||||
const cleanTimestamp = useMemo(() => {
|
||||
if (record.field !== 'timestamp') {
|
||||
return '';
|
||||
}
|
||||
return fieldData.value.replace(/^["']|["']$/g, '');
|
||||
}, [record.field, fieldData.value]);
|
||||
|
||||
const renderFieldContent = useCallback((): JSX.Element => {
|
||||
const commonStyles: React.CSSProperties = {
|
||||
color: Color.BG_SIENNA_400,
|
||||
whiteSpace: 'pre-wrap',
|
||||
tabSize: 4,
|
||||
};
|
||||
|
||||
switch (record.field) {
|
||||
case 'body':
|
||||
return (
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'timestamp':
|
||||
return (
|
||||
<span style={commonStyles}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
cleanTimestamp,
|
||||
DATE_TIME_FORMATS.UTC_US_MS,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<span style={commonStyles}>{removeEscapeCharacters(fieldData.value)}</span>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
record,
|
||||
fieldData,
|
||||
bodyHtml,
|
||||
textToCopy,
|
||||
handleChangeSelectedView,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
cleanTimestamp,
|
||||
]);
|
||||
|
||||
// Early return for body field with async processing
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isListViewPanel &&
|
||||
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterInLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter out value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterOutLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['!='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isLiveLogsOrInfraPage && (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={
|
||||
<div data-log-detail-ignore="true">
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
trigger="hover"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
className="filter-btn periscope-btn"
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
{renderFieldContent()}
|
||||
</CopyClipboardHOC>
|
||||
{!isListViewPanel &&
|
||||
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterInLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter out value" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
icon={
|
||||
isfilterOutLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={onClickHandler(
|
||||
OPERATORS['!='],
|
||||
fieldFilterKey,
|
||||
parseFieldValue(fieldData.value),
|
||||
dataType,
|
||||
fieldType,
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isLiveLogsOrInfraPage && (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={
|
||||
<div data-log-detail-ignore="true">
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
trigger="hover"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
className="filter-btn periscope-btn"
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableViewActions.defaultProps = {
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
@@ -0,0 +1,366 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import { RESTRICTED_SELECTED_FIELDS } from '../../config';
|
||||
import TableViewActions from '../TableViewActions';
|
||||
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
|
||||
|
||||
// Mock data for tests
|
||||
let mockCopyToClipboard: jest.Mock;
|
||||
let mockNotificationsSuccess: jest.Mock;
|
||||
|
||||
// Mock the components and hooks
|
||||
jest.mock('components/Logs/CopyClipboardHOC', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
textToCopy,
|
||||
entityKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
textToCopy: string;
|
||||
entityKey: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
className="CopyClipboardHOC"
|
||||
data-testid={`copy-clipboard-${entityKey}`}
|
||||
data-text-to-copy={textToCopy}
|
||||
onClick={(): void => {
|
||||
if (mockCopyToClipboard) {
|
||||
mockCopyToClipboard(textToCopy);
|
||||
}
|
||||
if (mockNotificationsSuccess) {
|
||||
mockNotificationsSuccess({
|
||||
message: `${entityKey} copied to clipboard`,
|
||||
key: `${entityKey} copied to clipboard`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../useAsyncJSONProcessing', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const antd = jest.requireActual('antd');
|
||||
return {
|
||||
...antd,
|
||||
// Render popover content inline to make its children testable
|
||||
Popover: ({ content, children }: any): JSX.Element => (
|
||||
<div data-testid="popover">
|
||||
<div data-testid="popover-content">{content}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
|
||||
} => ({
|
||||
formatTimezoneAdjustedTimestamp: (timestamp: string): string => timestamp,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
state: null;
|
||||
} => ({
|
||||
pathname: '/test',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('hooks/saveViews/useGetSavedViewParams');
|
||||
|
||||
describe('TableViewActions', () => {
|
||||
const TEST_VALUE = 'test value';
|
||||
const TEST_FIELD = 'test-field';
|
||||
const ACTION_BUTTON_TEST_ID = '.action-btn';
|
||||
const defaultProps = {
|
||||
fieldData: {
|
||||
field: TEST_FIELD,
|
||||
value: TEST_VALUE,
|
||||
},
|
||||
record: {
|
||||
key: 'test-key',
|
||||
field: TEST_FIELD,
|
||||
value: TEST_VALUE,
|
||||
},
|
||||
isListViewPanel: false,
|
||||
isfilterInLoading: false,
|
||||
isfilterOutLoading: false,
|
||||
onClickHandler: jest.fn(),
|
||||
handleChangeSelectedView: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard = jest.fn();
|
||||
mockNotificationsSuccess = jest.fn();
|
||||
defaultProps.onClickHandler = jest.fn();
|
||||
defaultProps.handleChangeSelectedView = jest.fn();
|
||||
|
||||
// Default mock for useAsyncJSONProcessing
|
||||
const mockUseAsyncJSONProcessing = jest.mocked(useAsyncJSONProcessing);
|
||||
mockUseAsyncJSONProcessing.mockReturnValue({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Default mock for useQueryBuilder
|
||||
jest.mocked(useQueryBuilder).mockReturnValue({
|
||||
stagedQuery: null,
|
||||
updateQueriesData: jest.fn((query, type, callback) => {
|
||||
const updatedBuilder = {
|
||||
...query.builder,
|
||||
[type]: query.builder[type].map(callback),
|
||||
};
|
||||
return {
|
||||
...query,
|
||||
builder: updatedBuilder,
|
||||
};
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Default mock for useGetSavedViewParams
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(TEST_VALUE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render action buttons for restricted fields', () => {
|
||||
RESTRICTED_SELECTED_FIELDS.forEach((field) => {
|
||||
const { container } = render(
|
||||
<TableViewActions
|
||||
fieldData={{
|
||||
...defaultProps.fieldData,
|
||||
field,
|
||||
}}
|
||||
record={{
|
||||
...defaultProps.record,
|
||||
field,
|
||||
}}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
// Verify that action buttons are not rendered for restricted fields
|
||||
expect(
|
||||
container.querySelector(ACTION_BUTTON_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render action buttons for non-restricted fields', () => {
|
||||
const { container } = render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
// Verify that action buttons are rendered for non-restricted fields
|
||||
expect(container.querySelector(ACTION_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleChangeSelectedView when clicking group by', () => {
|
||||
const mockStagedQuery = {
|
||||
id: 'test-query-id',
|
||||
queryType: 'queryBuilder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregateOperator: 'count',
|
||||
functions: [],
|
||||
filter: {},
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: '',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const mockUpdateQueriesData = jest.fn((query, type, callback) => {
|
||||
const section = query.builder?.[type];
|
||||
if (!Array.isArray(section)) {
|
||||
return query;
|
||||
}
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
[type]: section.map(callback),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mocked(useQueryBuilder).mockReturnValue({
|
||||
stagedQuery: mockStagedQuery,
|
||||
updateQueriesData: mockUpdateQueriesData,
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel={defaultProps.isListViewPanel}
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Group By Attribute'));
|
||||
|
||||
expect(defaultProps.handleChangeSelectedView).toHaveBeenCalledWith(
|
||||
ExplorerViews.TIMESERIES,
|
||||
expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: TEST_FIELD,
|
||||
type: '',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render action buttons in list view panel', () => {
|
||||
const { container } = render(
|
||||
<TableViewActions
|
||||
fieldData={defaultProps.fieldData}
|
||||
record={defaultProps.record}
|
||||
isListViewPanel
|
||||
isfilterInLoading={defaultProps.isfilterInLoading}
|
||||
isfilterOutLoading={defaultProps.isfilterOutLoading}
|
||||
onClickHandler={defaultProps.onClickHandler}
|
||||
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
// Verify that action buttons are not rendered in list view panel
|
||||
expect(
|
||||
container.querySelector(ACTION_BUTTON_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy non-JSON body text without quotes when user clicks on body', () => {
|
||||
// Setup: body field with surrounding quotes
|
||||
const bodyValueWithQuotes =
|
||||
'"FeatureFlag \'kafkaQueueProblems\' is enabled, sleeping 1 second"';
|
||||
const expectedCopiedText =
|
||||
"FeatureFlag 'kafkaQueueProblems' is enabled, sleeping 1 second";
|
||||
|
||||
const bodyProps = {
|
||||
fieldData: {
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
record: {
|
||||
key: 'body-key',
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
isListViewPanel: false,
|
||||
isfilterInLoading: false,
|
||||
isfilterOutLoading: false,
|
||||
onClickHandler: jest.fn(),
|
||||
handleChangeSelectedView: jest.fn(),
|
||||
};
|
||||
|
||||
// Render component with body field
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={bodyProps.fieldData}
|
||||
record={bodyProps.record}
|
||||
isListViewPanel={bodyProps.isListViewPanel}
|
||||
isfilterInLoading={bodyProps.isfilterInLoading}
|
||||
isfilterOutLoading={bodyProps.isfilterOutLoading}
|
||||
onClickHandler={bodyProps.onClickHandler}
|
||||
handleChangeSelectedView={bodyProps.handleChangeSelectedView}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the clickable copy area for body
|
||||
const copyArea = screen.getByTestId('copy-clipboard-body');
|
||||
|
||||
// Verify it has the correct text to copy (without quotes)
|
||||
expect(copyArea).toHaveAttribute('data-text-to-copy', expectedCopiedText);
|
||||
|
||||
// Action: User clicks on body content
|
||||
fireEvent.click(copyArea);
|
||||
|
||||
// Assert: Text was copied without surrounding quotes
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(expectedCopiedText);
|
||||
|
||||
// Assert: Success notification shown
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'body copied to clipboard',
|
||||
key: 'body copied to clipboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { jsonToDataNodes, recursiveParseJSON } from '../utils';
|
||||
|
||||
const MAX_BODY_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
// Hook for async JSON processing
|
||||
const useAsyncJSONProcessing = (
|
||||
value: string | Record<string, unknown>,
|
||||
shouldProcess: boolean,
|
||||
handleChangeSelectedView?: ChangeViewFunctionType,
|
||||
): {
|
||||
isLoading: boolean;
|
||||
treeData: any[] | null;
|
||||
error: string | null;
|
||||
} => {
|
||||
const [jsonState, setJsonState] = useState<{
|
||||
isLoading: boolean;
|
||||
treeData: any[] | null;
|
||||
error: string | null;
|
||||
}>({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const processingRef = useRef<boolean>(false);
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect((): (() => void) => {
|
||||
if (!shouldProcess || processingRef.current) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
// When value is already a parsed object skip the size check and JSON parsing
|
||||
const parseBody = (): Record<string, unknown> | null => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
const byteSize = new Blob([value as string]).size;
|
||||
if (byteSize > MAX_BODY_BYTES) {
|
||||
return null;
|
||||
}
|
||||
return recursiveParseJSON(value as string);
|
||||
};
|
||||
|
||||
processingRef.current = true;
|
||||
setJsonState({ isLoading: true, treeData: null, error: null });
|
||||
|
||||
// Option 1: Using setTimeout for non-blocking processing
|
||||
const processAsync = (): void => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
setJsonState({ isLoading: false, treeData, error: null });
|
||||
} else {
|
||||
setJsonState({ isLoading: false, treeData: null, error: null });
|
||||
}
|
||||
} catch (error) {
|
||||
setJsonState({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: error instanceof Error ? error.message : 'Parsing failed',
|
||||
});
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Option 2: Using requestIdleCallback for better performance
|
||||
const processWithIdleCallback = (): void => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(): void => {
|
||||
try {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
setJsonState({ isLoading: false, treeData, error: null });
|
||||
} else {
|
||||
setJsonState({ isLoading: false, treeData: null, error: null });
|
||||
}
|
||||
} catch (error) {
|
||||
setJsonState({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: error instanceof Error ? error.message : 'Parsing failed',
|
||||
});
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
} else {
|
||||
processAsync();
|
||||
}
|
||||
};
|
||||
|
||||
processWithIdleCallback();
|
||||
|
||||
// Cleanup function
|
||||
return (): void => {
|
||||
processingRef.current = false;
|
||||
};
|
||||
}, [value, shouldProcess, isBodyJsonQueryEnabled, handleChangeSelectedView]);
|
||||
|
||||
return jsonState;
|
||||
};
|
||||
|
||||
export default useAsyncJSONProcessing;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import BodyTitleRenderer from '../BodyTitleRenderer';
|
||||
|
||||
let mockSetCopy: jest.Mock;
|
||||
const mockNotification = jest.fn();
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog', () => ({
|
||||
useActiveLog: (): any => ({
|
||||
onAddToQuery: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): any => {
|
||||
mockSetCopy = jest.fn();
|
||||
return [{ value: null }, mockSetCopy];
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotification,
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
open: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BodyTitleRenderer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should copy primitive value when node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="name"
|
||||
nodeKey="user.name"
|
||||
value="John"
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('name'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('John');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('user.name'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy array element value when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="0"
|
||||
nodeKey="items[*].0"
|
||||
value="arrayElement"
|
||||
parentIsArray
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('0'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('arrayElement');
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy entire object when object node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testObject = { id: 123, active: true };
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="metadata"
|
||||
nodeKey="user.metadata"
|
||||
value={testObject}
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('metadata'));
|
||||
|
||||
await waitFor(() => {
|
||||
const callArg = mockSetCopy.mock.calls[0][0];
|
||||
const expectedJson = JSON.stringify(testObject, null, 2);
|
||||
expect(callArg).toBe(expectedJson);
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('object copied'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
@@ -51,6 +53,7 @@ export function useLogAttributeActions({
|
||||
isListViewPanel = false,
|
||||
onApplyLogFilter,
|
||||
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
@@ -58,6 +61,8 @@ export function useLogAttributeActions({
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
const isLiveLogs = pathname === ROUTES.LIVE_LOGS;
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
const target = buildLogFilterTarget(
|
||||
@@ -214,7 +219,8 @@ export function useLogAttributeActions({
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView ||
|
||||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.groupBySupported,
|
||||
.groupBySupported ||
|
||||
isLiveLogs,
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.REPLACE_FILTER,
|
||||
@@ -222,7 +228,7 @@ export function useLogAttributeActions({
|
||||
icon: <RefreshCw size={12} />,
|
||||
onClick: replaceFilter,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView || isRestricted(fieldKeyPath),
|
||||
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
@@ -230,6 +236,7 @@ export function useLogAttributeActions({
|
||||
groupBy,
|
||||
replaceFilter,
|
||||
isBodyJsonQueryEnabled,
|
||||
isLiveLogs,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import Convert from 'ansi-to-html';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import dompurify from 'dompurify';
|
||||
import { uniqueId } from 'lodash-es';
|
||||
import {
|
||||
ILog,
|
||||
ILogAggregateAttributesResources,
|
||||
@@ -10,6 +13,7 @@ import {
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
import BodyTitleRenderer from './BodyTitleRenderer';
|
||||
import { typeToArrayTypeMapper } from './config';
|
||||
import { AnyObject, IFieldAttributes } from './LogDetailedView.types';
|
||||
|
||||
@@ -36,6 +40,135 @@ export const recursiveParseJSON = (obj: string): Record<string, unknown> => {
|
||||
}
|
||||
};
|
||||
|
||||
type JsonToDataNodesOptions = {
|
||||
parentKey?: string;
|
||||
parentIsArray?: boolean;
|
||||
isBodyJsonQueryEnabled?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
};
|
||||
|
||||
type ComputeDataNodeOptions = {
|
||||
key: string;
|
||||
valueIsArray: boolean;
|
||||
value: unknown;
|
||||
nodeKey: string;
|
||||
parentIsArray: boolean;
|
||||
isBodyJsonQueryEnabled?: boolean;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
};
|
||||
|
||||
export const computeDataNode = ({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled = false,
|
||||
handleChangeSelectedView,
|
||||
}: ComputeDataNodeOptions): DataNode => ({
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={`${key} ${valueIsArray ? '[...]' : ''}`}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
children: jsonToDataNodes(value as Record<string, unknown>, {
|
||||
parentKey: valueIsArray
|
||||
? `${nodeKey}${isBodyJsonQueryEnabled ? '[]' : '[*]'}`
|
||||
: nodeKey,
|
||||
parentIsArray: valueIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
}),
|
||||
});
|
||||
|
||||
export function jsonToDataNodes(
|
||||
json: Record<string, unknown>,
|
||||
options: JsonToDataNodesOptions = {},
|
||||
): DataNode[] {
|
||||
const {
|
||||
parentKey = '',
|
||||
parentIsArray = false,
|
||||
isBodyJsonQueryEnabled = false,
|
||||
handleChangeSelectedView,
|
||||
} = options;
|
||||
|
||||
return Object.entries(json).map(([key, value]) => {
|
||||
let nodeKey = parentKey || key;
|
||||
if (parentIsArray) {
|
||||
nodeKey += `.${value}`;
|
||||
} else if (parentKey) {
|
||||
nodeKey += `.${key}`;
|
||||
}
|
||||
|
||||
const valueIsArray = Array.isArray(value);
|
||||
|
||||
if (parentIsArray) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={value as string}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
children: jsonToDataNodes(
|
||||
{},
|
||||
{
|
||||
parentKey: nodeKey,
|
||||
parentIsArray: valueIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode({
|
||||
key,
|
||||
valueIsArray,
|
||||
value,
|
||||
nodeKey,
|
||||
parentIsArray,
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
});
|
||||
}
|
||||
return {
|
||||
key: uniqueId(),
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={key}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenObject(obj: AnyObject, prefix = ''): AnyObject {
|
||||
return Object.keys(obj).reduce((acc: AnyObject, k: string): AnyObject => {
|
||||
const pre = prefix.length ? `${prefix}.` : '';
|
||||
@@ -121,6 +254,14 @@ export const getFieldAttributes = (field: string): IFieldAttributes => {
|
||||
// Returns key to be used when filtering for `field` via
|
||||
// the query builder. This is useful for powering filtering
|
||||
// by field values from log details view.
|
||||
export const filterKeyForField = (field: string): string => {
|
||||
// Must work for all 3 of the following types of cases
|
||||
// timestamp -> timestamp
|
||||
// attributes_string.log.file -> log.file
|
||||
// resources_string.k8s.pod.name -> k8s.pod.name
|
||||
const fieldAttribs = getFieldAttributes(field);
|
||||
return fieldAttribs?.newField || field;
|
||||
};
|
||||
|
||||
export const aggregateAttributesResourcesToObject = (
|
||||
logData: ILog,
|
||||
@@ -277,6 +418,16 @@ export const escapeHtml = (unsafe: string): string =>
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// parse field value to remove escaping characters
|
||||
export const parseFieldValue = (value: string): string => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// now we do not want to render colors everywhere like in tooltip and monaco editor hence we remove such codes to make
|
||||
// the log line readable
|
||||
export const removeEscapeCharacters = (str: string): string =>
|
||||
(str ?? '')
|
||||
.replace(/\\x1[bB][[0-9;]*m/g, '')
|
||||
@@ -307,6 +458,10 @@ export const unescapeString = (str: string): string =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
); // Replaces Unicode escape sequences
|
||||
|
||||
export function removeExtraSpaces(input: string): string {
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function findKeyPath(
|
||||
obj: AnyObject,
|
||||
targetKey: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
@@ -6,6 +6,7 @@ import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
@@ -13,18 +14,18 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
showFrequencyChart,
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
explorerActions,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
showFrequencyChart: boolean;
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
explorerActions: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -59,43 +60,48 @@ function LogsActionsContainer({
|
||||
<div className="logs-actions-container">
|
||||
<div className="tab-options">
|
||||
<div className="tab-options-left">
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tab-options-right">
|
||||
{explorerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<>
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{config.fieldsSelector && (
|
||||
|
||||
@@ -187,7 +187,6 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -141,13 +140,6 @@ function LogsExplorerViewsContainer({
|
||||
[selectedPanelType, requestData],
|
||||
);
|
||||
|
||||
const explorerActions = useMemo(
|
||||
() => (
|
||||
<ExplorerActions query={exportDefaultQuery} sourcepage={DataSource.LOGS} />
|
||||
),
|
||||
[exportDefaultQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: listChartData,
|
||||
isFetching: isFetchingListChartData,
|
||||
@@ -424,14 +416,14 @@ function LogsExplorerViewsContainer({
|
||||
return (
|
||||
<div className="logs-explorer-views-container">
|
||||
<div className="logs-explorer-views-types">
|
||||
{!showLiveLogs && selectedPanelType === PANEL_TYPES.LIST && (
|
||||
{!showLiveLogs && (
|
||||
<LogsActionsContainer
|
||||
listQuery={listQuery}
|
||||
selectedPanelType={selectedPanelType}
|
||||
showFrequencyChart={showFrequencyChart}
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
explorerActions={explorerActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -482,23 +474,21 @@ function LogsExplorerViewsContainer({
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
<div className="table-view-container">
|
||||
<div className="table-view-container-header">
|
||||
{explorerActions}
|
||||
{data && !isError && (
|
||||
{data && !isError && (
|
||||
<div className="table-view-container-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.metrics}
|
||||
fileName="logs-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
|
||||
@@ -394,7 +394,6 @@ function Explorer(): JSX.Element {
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -52,7 +51,6 @@ function TimeSeries({
|
||||
showYAxisUnitSelector,
|
||||
metrics,
|
||||
isCancelled = false,
|
||||
exportDefaultQuery,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -274,9 +272,6 @@ function TimeSeries({
|
||||
metricName;
|
||||
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
const exportQuery = changeLayoutForOneChartPerQuery
|
||||
? queryPayloads[index]
|
||||
: exportDefaultQuery;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -317,14 +312,6 @@ function TimeSeries({
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={
|
||||
<ExplorerActions
|
||||
query={stagedQuery ? exportQuery : null}
|
||||
sourcepage={DataSource.METRICS}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isOneChartPerQuery={changeLayoutForOneChartPerQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
@@ -147,11 +146,9 @@ function renderExplorer(): void {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<TooltipProvider>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</TooltipProvider>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
|
||||
import TimeSeries from '../TimeSeries';
|
||||
import { TimeSeriesProps } from '../types';
|
||||
@@ -72,7 +71,6 @@ function renderTimeSeries(
|
||||
yAxisUnit="count"
|
||||
setYAxisUnit={mockSetYAxisUnit}
|
||||
showYAxisUnitSelector={false}
|
||||
exportDefaultQuery={initialQueriesMap.metrics}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
@@ -18,5 +17,4 @@ export interface TimeSeriesProps {
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
showYAxisUnitSelector: boolean;
|
||||
isCancelled?: boolean;
|
||||
exportDefaultQuery: Query;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import ResizeTable from 'components/ResizeTable/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { CircleArrowDown, CircleArrowRight, Focus } from '@signozhq/icons';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricAttributes } from 'api/generated/services/metrics';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
|
||||
@@ -21,7 +21,7 @@ import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { PenLine, Save, X } from '@signozhq/icons';
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export enum MetricsExplorerEvents {
|
||||
YAxisUnitApplied = 'Metrics Explorer: Y axis unit applied',
|
||||
AddToAlertClicked = 'Metrics Explorer: Add to alert clicked',
|
||||
AddToDashboardClicked = 'Metrics Explorer: Add to dashboard clicked',
|
||||
AddToDashboardSuccessful = 'Metrics Explorer: Add to dashboard successful',
|
||||
SaveViewClicked = 'Metrics Explorer: Save view clicked',
|
||||
SearchApplied = 'Metrics Explorer: Search applied',
|
||||
ViewEdited = 'Metrics Explorer: View edited',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
.timeSeriesView {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.time-series-view {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
padding: 0px 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import Uplot from 'components/Uplot';
|
||||
@@ -52,7 +50,7 @@ import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
|
||||
import styles from './TimeSeriesView.module.scss';
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
function TimeSeriesView({
|
||||
data,
|
||||
@@ -68,7 +66,6 @@ function TimeSeriesView({
|
||||
allowExport = false,
|
||||
exportFileName,
|
||||
onYAxisUnitChange,
|
||||
headerActions,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -255,31 +252,28 @@ function TimeSeriesView({
|
||||
);
|
||||
|
||||
const showExport = allowExport && !!data?.rawV5Response;
|
||||
const showHeader = showExport || !!onYAxisUnitChange || !!headerActions;
|
||||
const showHeader = showExport || !!onYAxisUnitChange;
|
||||
|
||||
return (
|
||||
<div className={cx('time-series-view', styles.timeSeriesView)}>
|
||||
<div className="time-series-view">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{showHeader && (
|
||||
<div className={styles.header} data-testid="time-series-view-header">
|
||||
<div className="time-series-view__header">
|
||||
<div>
|
||||
{onYAxisUnitChange && (
|
||||
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
{headerActions}
|
||||
{showExport && data?.rawV5Response && (
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showExport && data?.rawV5Response && (
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -350,8 +344,6 @@ interface TimeSeriesViewProps {
|
||||
// Opt-in: render the y-axis unit selector in the header (views without their
|
||||
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
// Rendered in the header ahead of the export menu.
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
TimeSeriesView.defaultProps = {
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('TimeSeriesView header gating', () => {
|
||||
});
|
||||
|
||||
it('renders no header row when neither export nor unit selector is enabled', () => {
|
||||
const { queryByTestId } = renderView({ withRawV5: false });
|
||||
expect(queryByTestId('time-series-view-header')).toBeNull();
|
||||
const { container } = renderView({ withRawV5: false });
|
||||
expect(container.querySelector('.time-series-view__header')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,4 +207,4 @@ export const routesToSkip = [
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER];
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -56,7 +55,6 @@ interface ListViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
@@ -64,7 +62,6 @@ function ListView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
@@ -230,7 +227,6 @@ function ListView({
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
{headerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
@@ -276,7 +272,6 @@ function ListView({
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -31,12 +30,10 @@ function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -104,17 +101,14 @@ function TableView({
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && (
|
||||
{!isError && data && (
|
||||
<div className="traces-table-view-header">
|
||||
{headerActions}
|
||||
{data && (
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
)}
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
@@ -131,7 +125,6 @@ function TableView({
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -41,7 +40,6 @@ interface TracesViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
@@ -49,7 +47,6 @@ function TracesView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -158,7 +155,6 @@ function TracesView({
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
{headerActions}
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
@@ -191,7 +187,6 @@ function TracesView({
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
|
||||
183
frontend/src/hooks/infraMonitoring/useHandleLogsPagination.tsx
Normal file
183
frontend/src/hooks/infraMonitoring/useHandleLogsPagination.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface TimeRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface UsePaginatedLogsProps {
|
||||
timeRange: TimeRange;
|
||||
filters: IBuilderQuery['filters'];
|
||||
queryKeyFilters?: string[];
|
||||
excludeFilterKeys?: string[];
|
||||
basePayload: GetQueryResultsProps;
|
||||
}
|
||||
|
||||
interface UseHandleLogsPagination {
|
||||
logs: ILog[];
|
||||
hasReachedEndOfLogs: boolean;
|
||||
isPaginating: boolean;
|
||||
currentPage: number;
|
||||
resetLogsList: boolean;
|
||||
setIsPaginating: Dispatch<SetStateAction<boolean>>;
|
||||
handleNewData: (currentData: any) => void;
|
||||
loadMoreLogs: () => void;
|
||||
shouldResetPage: boolean;
|
||||
queryPayload: GetQueryResultsProps;
|
||||
}
|
||||
|
||||
export const useHandleLogsPagination = ({
|
||||
timeRange,
|
||||
filters,
|
||||
queryKeyFilters = [],
|
||||
excludeFilterKeys = [],
|
||||
basePayload,
|
||||
}: UsePaginatedLogsProps): UseHandleLogsPagination => {
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [hasReachedEndOfLogs, setHasReachedEndOfLogs] = useState(false);
|
||||
const [restFilters, setRestFilters] = useState<TagFilterItem[]>([]);
|
||||
const [resetLogsList, setResetLogsList] = useState<boolean>(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [prevTimeRange, setPrevTimeRange] = useState<TimeRange | null>(
|
||||
timeRange,
|
||||
);
|
||||
const [isPaginating, setIsPaginating] = useState(false);
|
||||
|
||||
const { shouldResetPage, newRestFilters } = useMemo(() => {
|
||||
const newRestFilters = filters?.items?.filter((item) => {
|
||||
const keyToCheck = item.key?.key ?? '';
|
||||
return (
|
||||
!queryKeyFilters.includes(keyToCheck) &&
|
||||
!excludeFilterKeys.includes(keyToCheck)
|
||||
);
|
||||
});
|
||||
|
||||
const areFiltersSame = isEqual(restFilters, newRestFilters);
|
||||
|
||||
const shouldResetPage =
|
||||
!areFiltersSame ||
|
||||
timeRange.startTime !== prevTimeRange?.startTime ||
|
||||
timeRange.endTime !== prevTimeRange?.endTime;
|
||||
|
||||
return { shouldResetPage, newRestFilters };
|
||||
}, [
|
||||
filters,
|
||||
timeRange,
|
||||
prevTimeRange,
|
||||
queryKeyFilters,
|
||||
excludeFilterKeys,
|
||||
restFilters,
|
||||
]);
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
if (shouldResetPage) {
|
||||
return 1;
|
||||
}
|
||||
return page;
|
||||
}, [shouldResetPage, page]);
|
||||
|
||||
// Handle data updates
|
||||
const handleNewData = useCallback(
|
||||
(currentData: any) => {
|
||||
if (!currentData[0].list) {
|
||||
setHasReachedEndOfLogs(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLogs: ILog[] =
|
||||
currentData[0].list?.map((item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
})) || [];
|
||||
|
||||
if (resetLogsList) {
|
||||
setLogs(currentLogs);
|
||||
setResetLogsList(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLogs = currentLogs.filter(
|
||||
(newLog) => !logs.some((existingLog) => isEqual(existingLog, newLog)),
|
||||
);
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
setLogs((prev) => [...prev, ...newLogs]);
|
||||
}
|
||||
},
|
||||
[logs, resetLogsList],
|
||||
);
|
||||
|
||||
// Reset logic
|
||||
useEffect(() => {
|
||||
if (shouldResetPage) {
|
||||
setPage(1);
|
||||
setLogs([]);
|
||||
setResetLogsList(true);
|
||||
}
|
||||
|
||||
setPrevTimeRange(timeRange);
|
||||
setRestFilters(newRestFilters || []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldResetPage, timeRange]);
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (!logs.length) {
|
||||
return;
|
||||
}
|
||||
setPage((prev) => prev + 1);
|
||||
setIsPaginating(true);
|
||||
}, [logs]);
|
||||
|
||||
const queryPayload = useMemo(
|
||||
() => ({
|
||||
...basePayload,
|
||||
query: {
|
||||
...basePayload.query,
|
||||
builder: {
|
||||
...basePayload.query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...basePayload.query.builder.queryData[0],
|
||||
pageSize: DEFAULT_PER_PAGE_VALUE,
|
||||
offset: (currentPage - 1) * DEFAULT_PER_PAGE_VALUE,
|
||||
orderBy: [
|
||||
{ columnName: 'timestamp', order: ORDERBY_FILTERS.DESC },
|
||||
{ columnName: 'id', order: ORDERBY_FILTERS.DESC },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
[basePayload, currentPage],
|
||||
);
|
||||
|
||||
return {
|
||||
logs,
|
||||
hasReachedEndOfLogs,
|
||||
isPaginating,
|
||||
currentPage,
|
||||
resetLogsList,
|
||||
queryPayload,
|
||||
setIsPaginating,
|
||||
handleNewData,
|
||||
loadMoreLogs,
|
||||
shouldResetPage,
|
||||
};
|
||||
};
|
||||
9
frontend/src/lib/logs/fieldSearch.ts
Normal file
9
frontend/src/lib/logs/fieldSearch.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const fieldSearchFilter = (
|
||||
searchSpace = '',
|
||||
currentValue = '',
|
||||
): boolean => {
|
||||
if (!currentValue || !searchSpace) {
|
||||
return true;
|
||||
}
|
||||
return searchSpace.toLowerCase().indexOf(currentValue.toLowerCase()) !== -1;
|
||||
};
|
||||
@@ -295,11 +295,7 @@ const account = (
|
||||
provider,
|
||||
providerAccountId: ACCOUNTS[provider][index],
|
||||
config: accountConfig(provider),
|
||||
agentReport: {
|
||||
timestampMillis: Date.now() - 45 * 1000,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
agentReport: { timestampMillis: Date.now() - 45 * 1000, data: null },
|
||||
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
removedAt: null,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -30,7 +29,6 @@ function TimeSeriesViewContainer({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -128,7 +126,6 @@ function TimeSeriesViewContainer({
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -140,13 +137,11 @@ interface TimeSeriesViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
|
||||
@@ -13,8 +13,6 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import { getExportPanelType } from 'container/ExplorerActions/utils';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
@@ -196,27 +194,6 @@ function TracesExplorer(): JSX.Element {
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const exportDashboardQuery = useMemo(
|
||||
() =>
|
||||
getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
),
|
||||
[exportDefaultQuery, panelType, options],
|
||||
);
|
||||
|
||||
const explorerActions = useMemo(
|
||||
() => (
|
||||
<ExplorerActions
|
||||
query={stagedQuery ? exportDefaultQuery : null}
|
||||
dashboardQuery={stagedQuery ? exportDashboardQuery : null}
|
||||
sourcepage={DataSource.TRACES}
|
||||
/>
|
||||
),
|
||||
[stagedQuery, exportDefaultQuery, exportDashboardQuery],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
@@ -341,7 +318,6 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -353,7 +329,6 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -366,7 +341,6 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,7 +351,6 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LIVE_LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LIST_LICENSES: ['ADMIN'],
|
||||
LOGS_INDEX_FIELDS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS_PIPELINES: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -168,6 +169,7 @@ export const routeWithInitialAuthZSupport = {
|
||||
TRACE_DETAIL: true,
|
||||
LOGS: true,
|
||||
LOGS_EXPLORER: true,
|
||||
LIVE_LOGS: true,
|
||||
ROLES_SETTINGS: true,
|
||||
ROLE_CREATE: true,
|
||||
ROLE_DETAILS: true,
|
||||
|
||||
@@ -187,7 +187,7 @@ func (store *config) ListChannels(ctx context.Context, orgID string, params *ale
|
||||
}
|
||||
|
||||
if !params.Kind.IsZero() {
|
||||
q = q.Where("type = ?", params.Kind.StringValue())
|
||||
q = q.Where("type = ?", params.Kind.ToStoredType())
|
||||
}
|
||||
|
||||
q = q.
|
||||
|
||||
@@ -98,12 +98,11 @@ func (handler *handler) ListChannels(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
// This ensures that the UI receives an empty array instead of null
|
||||
v1Channels := make([]*alertmanagertypes.Channel, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
v1Channels = append(v1Channels, channel.ToV1Channel())
|
||||
if len(channels) == 0 {
|
||||
channels = make([]*alertmanagertypes.Channel, 0)
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, v1Channels)
|
||||
render.Success(rw, http.StatusOK, channels)
|
||||
}
|
||||
|
||||
func (handler *handler) ListAllChannels(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -153,7 +152,7 @@ func (handler *handler) GetChannelByID(rw http.ResponseWriter, req *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, channel.ToV1Channel())
|
||||
render.Success(rw, http.StatusOK, channel)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateChannelByID(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -273,7 +272,7 @@ func (handler *handler) CreateChannel(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, channel.ToV1Channel())
|
||||
render.Success(rw, http.StatusCreated, channel)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -262,7 +262,7 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
}
|
||||
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
channel, receiver, err := postable.ToChannel(orgID)
|
||||
receiver, err := postable.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -280,6 +280,11 @@ func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := alertmanagertypes.NewChannelFromReceiverWithName(receiver, postable.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.configStore.CreateChannel(ctx, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
|
||||
return provider.configStore.Set(ctx, config)
|
||||
}))
|
||||
@@ -299,11 +304,15 @@ func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiver, err := channel.UpdateFromUpdatable(updatable)
|
||||
receiver, err := updatable.ToReceiver(channel.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := channel.Update(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -67,5 +67,23 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceThread",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get thread view for a trace",
|
||||
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order. Pages are fetched with the returned nextCursor.",
|
||||
RequestQuery: new(spantypes.QueryableThread),
|
||||
Response: new(spantypes.GettableTraceThread),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.15",
|
||||
Version: "v0.0.14",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
"resourceType": "containerApps",
|
||||
"metrics": {},
|
||||
"logs": {
|
||||
"categoryGroups": ["allLogs"]
|
||||
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -134,24 +134,6 @@ func (store *store) UpdateAccount(ctx context.Context, account *cloudintegration
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(account).
|
||||
Column("config").
|
||||
Column("updated_at").
|
||||
WherePK().
|
||||
Where("org_id = ?", account.OrgID).
|
||||
Where("provider = ?", account.Provider).
|
||||
Exec(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *store) UpdateAgentReport(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) error {
|
||||
_, err := store.
|
||||
store.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(account).
|
||||
Column("account_id").
|
||||
Column("last_agent_report").
|
||||
WherePK().
|
||||
Where("org_id = ?", account.OrgID).
|
||||
Where("provider = ?", account.Provider).
|
||||
|
||||
@@ -75,3 +75,25 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.QueryableThread)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
query, err := spantypes.NewThreadQuery(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetThread(r.Context(), mux.Vars(r)["traceID"], query)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -173,6 +173,19 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
|
||||
), nil
|
||||
}
|
||||
|
||||
func (m *module) GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spans, err := m.store.GetThreadSpans(ctx, traceID, summary, query.Cursor, query.Limit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, spans, query.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,12 +11,23 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
|
||||
var fullSpanColumns = []string{
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
}
|
||||
|
||||
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
switch fieldKey.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
@@ -123,16 +134,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
)
|
||||
sb.Select("DISTINCT ON (span_id) timestamp")
|
||||
sb.SelectMore(fullSpanColumns...)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
@@ -155,6 +158,44 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary, cursor *spantypes.ThreadCursor, limit int) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT ON (span_id) timestamp")
|
||||
sb.SelectMore(fullSpanColumns...)
|
||||
sb.SelectMore("attributes")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
// Reads only the JSON column; spans with messages only in the legacy maps are skipped.
|
||||
// todo(nitya): pick the column from the attribute evolution metadata.
|
||||
sb.Or(
|
||||
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIInputMessages))),
|
||||
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIOutputMessages))),
|
||||
),
|
||||
)
|
||||
if cursor != nil {
|
||||
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
|
||||
// ts_bucket_start bounds are what skip the data before the cursor.
|
||||
sb.Where(
|
||||
sb.GE("ts_bucket_start", int64(cursor.TimeUnixNano/uint64(time.Second))-1800),
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", cursor.TimeUnixNano)),
|
||||
sb.GT("(toUnixTimestamp64Nano(timestamp), span_id)", sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)),
|
||||
)
|
||||
}
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("span_id")
|
||||
sb.Limit(limit)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
|
||||
@@ -13,6 +13,7 @@ type Handler interface {
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
GetThread(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
@@ -20,4 +21,5 @@ type Module interface {
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
|
||||
}
|
||||
|
||||
@@ -566,24 +566,6 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
|
||||
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := v.(type) {
|
||||
case map[string]any:
|
||||
flattenJSONPaths(key, child, out)
|
||||
case telemetrystoretypes.JSONValue:
|
||||
flattenJSONPaths(key, child, out)
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
// unified "attributes" and "resource" keys, and parses the stringified `events`
|
||||
// and `links` columns into structured slices. Raw DB columns are removed.
|
||||
@@ -598,7 +580,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
flattenJSONPaths("", attrJSON, attributes)
|
||||
attrJSON.FlattenInto("", attributes)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
|
||||
@@ -257,7 +257,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddChannelSpecFactory(sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,737 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
var channelSpecBackfillKinds = []channelSpecBackfillKind{
|
||||
{kind: "slack", configsKey: "slack_configs", convert: convertSlackNotifierJSON},
|
||||
{kind: "email", configsKey: "email_configs", convert: convertEmailNotifierJSON},
|
||||
{kind: "webhook", configsKey: "webhook_configs", convert: convertWebhookNotifierJSON},
|
||||
{kind: "pagerduty", configsKey: "pagerduty_configs", convert: convertPagerdutyNotifierJSON},
|
||||
{kind: "opsgenie", configsKey: "opsgenie_configs", convert: convertOpsgenieNotifierJSON},
|
||||
{kind: "msteams", configsKey: "msteamsv2_configs", convert: convertMSTeamsNotifierJSON},
|
||||
{kind: "googlechat", configsKey: "googlechat_configs", convert: convertGoogleChatNotifierJSON},
|
||||
{kind: "jira", configsKey: "jira_configs", convert: convertJiraNotifierJSON},
|
||||
{kind: "jsmops", configsKey: "jsmops_configs", convert: convertJSMOpsNotifierJSON},
|
||||
{kind: "incidentio", configsKey: "incidentio_configs", convert: convertIncidentIONotifierJSON},
|
||||
}
|
||||
|
||||
func NewAddChannelSpecFactory(sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_channel_spec"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addChannelSpec{sqlschema: sqlschema, logger: ps.Logger}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addChannelSpec) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up adds the column and fills it from each channel's receiver, as a write
|
||||
// through a receiver does, pinning type to the kind the spec came from because
|
||||
// a read decodes the spec under it. The msteams kind, which v1 stored as
|
||||
// msteamsv2 after upstream's configs list, is renamed on every row. A receiver
|
||||
// v2 cannot represent, such as one carrying several notifiers or a notifier
|
||||
// kind v2 does not model, stays NULL and is logged; the repair endpoint is the
|
||||
// remedy for those.
|
||||
func (migration *addChannelSpec) Up(ctx context.Context, db *bun.DB) error {
|
||||
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("notification_channel"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("spec"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}, nil)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().Model((*channelSpecBackfillRow)(nil)).Set("type = ?", "msteams").Where("type = ?", "msteamsv2").Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([]*channelSpecBackfillRow, 0)
|
||||
if err := tx.NewSelect().Model(&rows).Where("spec IS NULL").OrderExpr("org_id, id").Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type orgStats struct{ total, filled, unrepresentable int }
|
||||
statsByOrg := map[string]*orgStats{}
|
||||
for _, row := range rows {
|
||||
stats, ok := statsByOrg[row.OrgID]
|
||||
if !ok {
|
||||
stats = &orgStats{}
|
||||
statsByOrg[row.OrgID] = stats
|
||||
}
|
||||
stats.total++
|
||||
|
||||
storedType, spec, err := channelSpecFromReceiverJSON(row.Data)
|
||||
if err != nil {
|
||||
stats.unrepresentable++
|
||||
migration.logger.WarnContext(ctx, "leaving notification channel without a v2 spec", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
encoded, err := marshalUnescaped(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*channelSpecBackfillRow)(nil)).
|
||||
Set("spec = ?", string(encoded)).
|
||||
Set("type = ?", storedType).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.filled++
|
||||
}
|
||||
|
||||
for _, orgID := range slices.Sorted(maps.Keys(statsByOrg)) {
|
||||
stats := statsByOrg[orgID]
|
||||
migration.logger.InfoContext(ctx, "filled v2 spec on notification channels", slog.String("org_id", orgID), slog.Int("total", stats.total), slog.Int("filled", stats.filled), slog.Int("unrepresentable", stats.unrepresentable))
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addChannelSpec) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type addChannelSpec struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type channelSpecBackfillRow struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
// notifierJSON is one entry of a receiver's *_configs list as stored in
|
||||
// notification_channel.data.
|
||||
type notifierJSON map[string]json.RawMessage
|
||||
|
||||
type channelSpecBackfillKind struct {
|
||||
kind string
|
||||
configsKey string
|
||||
convert func(notifierJSON) (map[string]any, error)
|
||||
}
|
||||
|
||||
// channelSpecFromReceiverJSON mirrors the v2 read of a stored receiver: one
|
||||
// notifier of a modelled kind, with the receiver's field names renamed to the
|
||||
// spec's and its unset templates left out. The type alongside is what a read
|
||||
// decodes the spec under.
|
||||
func channelSpecFromReceiverJSON(data string) (string, map[string]any, error) {
|
||||
receiver := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(data), &receiver); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
total := 0
|
||||
var found *channelSpecBackfillKind
|
||||
var notifier notifierJSON
|
||||
for key, raw := range receiver {
|
||||
if !strings.HasSuffix(key, "_configs") {
|
||||
continue
|
||||
}
|
||||
|
||||
var list []notifierJSON
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return "", nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
total += len(list)
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range channelSpecBackfillKinds {
|
||||
if channelSpecBackfillKinds[i].configsKey == key {
|
||||
found = &channelSpecBackfillKinds[i]
|
||||
notifier = list[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total > 1 {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries %d notifier configurations; only one per channel is supported", total)
|
||||
}
|
||||
if found == nil {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries no supported notifier configuration")
|
||||
}
|
||||
|
||||
spec, err := found.convert(notifier)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
sendResolved, err := notifier.boolValue("send_resolved")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
spec["sendResolved"] = sendResolved
|
||||
|
||||
return found.kind, spec, nil
|
||||
}
|
||||
|
||||
func convertSlackNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_url": "apiUrl", "channel": "channel"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "text": "text", "color": "color", "title_link": "titleLink", "pretext": "pretext", "fallback": "fallback", "footer": "footer"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiUrl"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "slack: api_url is required")
|
||||
}
|
||||
|
||||
fields, err := notifier.objectList("fields")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
converted := make([]map[string]any, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
item := map[string]any{}
|
||||
if err := field.copyStrings(item, map[string]string{"title": "title", "value": "value"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if field.has("short") {
|
||||
short, err := field.boolValue("short")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["short"] = short
|
||||
}
|
||||
converted = append(converted, item)
|
||||
}
|
||||
spec["fields"] = converted
|
||||
}
|
||||
|
||||
actions, err := notifier.objectList("actions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
converted := make([]map[string]any, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
item := map[string]any{}
|
||||
if err := action.copyStrings(item, map[string]string{"type": "type", "text": "text", "url": "url", "style": "style", "name": "name", "value": "value"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action.has("confirm") {
|
||||
confirm, err := action.object("confirm")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confirmation := map[string]any{}
|
||||
if err := confirm.copyStrings(confirmation, map[string]string{"text": "text", "title": "title", "ok_text": "okText", "dismiss_text": "dismissText"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["confirm"] = confirmation
|
||||
}
|
||||
converted = append(converted, item)
|
||||
}
|
||||
spec["actions"] = converted
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertEmailNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"to": "to"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"html": "html"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["to"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "email: to is required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"headers": "headers"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertWebhookNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"url": "url"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["url"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "webhook: url is required")
|
||||
}
|
||||
|
||||
if err := rejectUnsupportedHTTPConfigJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username, password, err := extractBasicAuthJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bearerToken, err := extractBearerTokenJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (username != "" || password != "") && bearerToken != "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "webhook: basic auth and bearer token cannot be combined")
|
||||
}
|
||||
spec["username"], spec["password"], spec["bearerToken"] = username, password, bearerToken
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertPagerdutyNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"routing_key": "routingKey", "url": "url", "severity": "severity", "component": "component", "group": "group", "class": "class"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"source": "source", "client": "client", "client_url": "clientUrl", "description": "description"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["routingKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "pagerduty: routing_key is required")
|
||||
}
|
||||
|
||||
details, err := notifier.object("details")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(details) > 0 {
|
||||
for key, raw := range details {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "pagerduty: details.%s is not a string", key)
|
||||
}
|
||||
}
|
||||
spec["details"] = notifier["details"]
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertOpsgenieNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_key": "apiKey", "api_url": "apiUrl", "priority": "priority"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"message": "message", "description": "description", "source": "source"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "opsgenie: api_key is required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"details": "details"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertMSTeamsNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
return convertWebhookURLNotifierJSON("msteams", notifier)
|
||||
}
|
||||
|
||||
func convertGoogleChatNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
return convertWebhookURLNotifierJSON("googlechat", notifier)
|
||||
}
|
||||
|
||||
func convertWebhookURLNotifierJSON(name string, notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"webhook_url": "webhookUrl"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "text": "text"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["webhookUrl"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s: webhook_url is required", name)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertJiraNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"site": "site", "project": "project", "issue_type": "issueType", "priority": "priority", "resolve_transition": "resolveTransition", "reopen_transition": "reopenTransition", "wont_fix_resolution": "wontFixResolution"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"summary": "summary", "description": "description", "reopen_duration": "reopenDuration"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"custom_fields": "customFields"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
labels, err := notifier.list("labels")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
spec["labels"] = notifier["labels"]
|
||||
}
|
||||
|
||||
if err := rejectUnsupportedHTTPConfigJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpConfig.has("authorization") {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jira: http_config.authorization is not supported")
|
||||
}
|
||||
email, apiToken, err := extractBasicAuthJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec["email"], spec["apiToken"] = email, apiToken
|
||||
|
||||
for _, required := range []string{"site", "project", "issueType", "email", "apiToken"} {
|
||||
if spec[required] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jira: %s is required", required)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertJSMOpsNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_key": "apiKey", "priority": "priority"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"message": "message", "description": "description", "tags": "tags"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jsmops: api_key is required")
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertIncidentIONotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"url": "url", "token": "token"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "description": "description"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["url"] == "" || spec["token"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "incidentio: url and token are required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"metadata": "metadata"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuthJSON(notifier notifierJSON) error {
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpConfig.has("basic_auth") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.basic_auth is not supported")
|
||||
}
|
||||
if httpConfig.has("authorization") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization is not supported")
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfigJSON(notifier)
|
||||
}
|
||||
|
||||
// rejectUnsupportedHTTPConfigJSON refuses every http_config setting the spec has
|
||||
// no field for, since a config that dropped it would unauthenticate or reroute
|
||||
// the channel on the next write. An absent follow_redirects or enable_http2 is
|
||||
// the upstream default, true; only an explicit false is refused.
|
||||
func rejectUnsupportedHTTPConfigJSON(notifier notifierJSON) error {
|
||||
if !notifier.has("http_config") {
|
||||
return nil
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range []string{"oauth2", "http_headers"} {
|
||||
if httpConfig.has(key) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"bearer_token", "bearer_token_file", "proxy_url", "no_proxy"} {
|
||||
value, err := httpConfig.stringValue(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
if httpConfig.has("proxy_from_environment") {
|
||||
fromEnvironment, err := httpConfig.boolValue("proxy_from_environment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEnvironment {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.proxy_from_environment is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
tlsConfig, err := httpConfig.object("tls_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key := range tlsConfig {
|
||||
if key != "insecure_skip_verify" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.tls_config is not supported")
|
||||
}
|
||||
}
|
||||
if tlsConfig.has("insecure_skip_verify") {
|
||||
insecure, err := tlsConfig.boolValue("insecure_skip_verify")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insecure {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.tls_config is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"follow_redirects", "enable_http2"} {
|
||||
if !httpConfig.has(key) {
|
||||
continue
|
||||
}
|
||||
enabled, err := httpConfig.boolValue(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s cannot be disabled", key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractBasicAuthJSON(httpConfig notifierJSON) (string, string, error) {
|
||||
basicAuth, err := httpConfig.object("basic_auth")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(basicAuth) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
for key := range basicAuth {
|
||||
if key != "username" && key != "password" {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.basic_auth.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
username, err := basicAuth.stringValue("username")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password, err := basicAuth.stringValue("password")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func extractBearerTokenJSON(httpConfig notifierJSON) (string, error) {
|
||||
if !httpConfig.has("authorization") {
|
||||
return "", nil
|
||||
}
|
||||
authorization, err := httpConfig.object("authorization")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for key := range authorization {
|
||||
if key != "type" && key != "credentials" {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
scheme, err := authorization.stringValue("type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization.type %q is not supported", scheme)
|
||||
}
|
||||
|
||||
return authorization.stringValue("credentials")
|
||||
}
|
||||
|
||||
// has reports a key that is present and not null.
|
||||
func (n notifierJSON) has(key string) bool {
|
||||
raw, ok := n[key]
|
||||
return ok && string(raw) != "null"
|
||||
}
|
||||
|
||||
func (n notifierJSON) stringValue(key string) (string, error) {
|
||||
if !n.has(key) {
|
||||
return "", nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) boolValue(key string) (bool, error) {
|
||||
if !n.has(key) {
|
||||
return false, nil
|
||||
}
|
||||
var value bool
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return false, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// object reads an absent key as an empty object; a caller that must tell the
|
||||
// two apart checks has first.
|
||||
func (n notifierJSON) object(key string) (notifierJSON, error) {
|
||||
if !n.has(key) {
|
||||
return notifierJSON{}, nil
|
||||
}
|
||||
value := notifierJSON{}
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) list(key string) ([]json.RawMessage, error) {
|
||||
if !n.has(key) {
|
||||
return nil, nil
|
||||
}
|
||||
var value []json.RawMessage
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) objectList(key string) ([]notifierJSON, error) {
|
||||
if !n.has(key) {
|
||||
return nil, nil
|
||||
}
|
||||
var value []notifierJSON
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// copyStrings writes each field as the spec's plain string, "" when absent.
|
||||
func (n notifierJSON) copyStrings(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.stringValue(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec[to] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyNonEmptyStrings leaves an empty field out, which is how the spec spells
|
||||
// an unset template.
|
||||
func (n notifierJSON) copyNonEmptyStrings(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.stringValue(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != "" {
|
||||
spec[to] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) copyNonEmptyObjects(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.object(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(value) > 0 {
|
||||
spec[to] = n[from]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -26,6 +30,20 @@ var (
|
||||
|
||||
type Channels = []*Channel
|
||||
|
||||
type GettableChannels = []*Channel
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchema is not the shape OpenAPI wants for a
|
||||
// discriminated union. OpenAPI's discriminator requires every oneOf branch to
|
||||
// be a $ref to a named component and a sibling property whose value selects
|
||||
// the variant. Our payload instead uses the *presence* of one of the 18
|
||||
// *_configs arrays to imply the type, so no discriminator can be attached.
|
||||
// Refactor PostableChannel into a {name, type, config} envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) so each notification kind
|
||||
// becomes a named component and the discriminator can be wired up properly.
|
||||
type PostableChannel struct {
|
||||
Receiver
|
||||
}
|
||||
|
||||
// Channel represents a single receiver of the alertmanager config.
|
||||
type Channel struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
@@ -37,51 +55,45 @@ type Channel struct {
|
||||
// reference, so it keeps the v1 wire tag and Name stays off the v1 contract.
|
||||
Name string `json:"-" bun:"name"`
|
||||
DisplayName string `json:"name" required:"true" bun:"display_name"`
|
||||
// TODO: type this as ChannelKind once v1 is gone.
|
||||
Type string `json:"type" required:"true" bun:"type"`
|
||||
Data string `json:"data" required:"true" bun:"data"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
|
||||
// Spec is the v2 spec a read returns, of the kind Type names. A v2 write
|
||||
// stores it as the caller wrote it and a v1 write derives it from the
|
||||
// defaulted receiver. Only a row the migration could not backfill has none.
|
||||
// StoredSpec is the column, written by fillSpec and decoded by AfterScanRow.
|
||||
Spec ChannelSpec `json:"-" bun:"-"`
|
||||
StoredSpec string `json:"-" bun:"spec,type:text,nullzero"`
|
||||
Type string `json:"type" required:"true" bun:"type"`
|
||||
Data string `json:"data" required:"true" bun:"data"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
}
|
||||
|
||||
var _ bun.AfterScanRowHook = (*Channel)(nil)
|
||||
|
||||
// AfterScanRow decodes the stored spec under Type, which bun cannot do column by
|
||||
// column because the spec's Go type depends on it.
|
||||
func (c *Channel) AfterScanRow(context.Context) error {
|
||||
if c.StoredSpec == "" {
|
||||
c.Spec = nil
|
||||
return nil
|
||||
// NewChannelFromReceiver creates a new Channel from a Receiver.
|
||||
// It can return nil if the receiver is the default receiver.
|
||||
// A receiver carries no internal name, so one is generated from its name.
|
||||
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)
|
||||
}
|
||||
|
||||
channelKind, ok := parseChannelKind(c.Type)
|
||||
if !ok {
|
||||
return errors.NewInternalf(errors.CodeInternal, "channel %q stores a spec under unmodelled type %q", c.DisplayName, c.Type)
|
||||
// Initialize channel with common fields
|
||||
channel := Channel{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Name: generateChannelName(receiver.Name),
|
||||
DisplayName: receiver.Name,
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
spec, _ := buildEmptyChannelSpecForKind(channelKind)
|
||||
if err := json.Unmarshal([]byte(c.StoredSpec), spec); err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q spec", c.DisplayName)
|
||||
}
|
||||
c.Spec = spec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Channel) fillSpec(spec ChannelSpec) error {
|
||||
stored, err := json.Marshal(spec)
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "marshal channel %q spec", c.DisplayName)
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "marshal receiver")
|
||||
}
|
||||
c.Spec, c.StoredSpec = spec, string(stored)
|
||||
channel.Data = string(data)
|
||||
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
const channelNameSuffixLen = 8
|
||||
@@ -125,6 +137,57 @@ func generateChannelName(displayName string) string {
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
// NewChannelFromReceiverWithName overrides the name that NewChannelFromReceiver
|
||||
// generates.
|
||||
func NewChannelFromReceiverWithName(receiver *Receiver, name string, orgID string) (*Channel, error) {
|
||||
channel, err := NewChannelFromReceiver(receiver, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel.Name = name
|
||||
|
||||
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,
|
||||
@@ -165,3 +228,64 @@ func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
stats["alertmanager.channel.count"] = int64(len(channels))
|
||||
return stats
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, c.Name, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.DisplayName != channel.DisplayName {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
// Unreachable while the name is passed in above rather than derived from the
|
||||
// receiver, which is why this is internal rather than invalid input.
|
||||
if c.Name != channel.Name {
|
||||
return errors.NewInternalf(ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel internal name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
type alias PostableChannel
|
||||
reflector := &jsonschema.Reflector{}
|
||||
|
||||
schema, err := reflector.Reflect(alias{}, jsonschema.DefinitionsPrefix("#/components/schemas/"))
|
||||
if err != nil {
|
||||
return jsonschema.Schema{}, err
|
||||
}
|
||||
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
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
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
@@ -16,20 +16,8 @@ import (
|
||||
type ChannelEmailConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
|
||||
Headers map[string]string `json:"headers,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelEmailConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultEmailConfig.VSendResolved)
|
||||
c.HTML.SetIfUnset(config.DefaultEmailConfig.HTML)
|
||||
|
||||
return c.Validate()
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) Validate() error {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -10,21 +13,8 @@ import (
|
||||
type ChannelGoogleChatConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelGoogleChatConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelGoogleChatConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(DefaultGoogleChatReceiverConfig.Title)
|
||||
c.Text.SetIfUnset(DefaultGoogleChatReceiverConfig.Text)
|
||||
|
||||
return c.Validate()
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) Validate() error {
|
||||
@@ -99,5 +89,15 @@ func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
|
||||
if c.WebhookURL == nil {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is required")
|
||||
}
|
||||
u, err := url.Parse(c.WebhookURL.String())
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid google chat webhook_url: %v", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use https")
|
||||
}
|
||||
if strings.ToLower(u.Hostname()) != "chat.googleapis.com" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use chat.googleapis.com")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ func TestNewReceiverGoogleChatWebhookURL(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", `{"name":"gc","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t"}]}`, false},
|
||||
{"http scheme rejected", `{"name":"gc","googlechat_configs":[{"webhook_url":"http://chat.googleapis.com/v1/spaces/x/messages"}]}`, true},
|
||||
{"wrong host rejected", `{"name":"gc","googlechat_configs":[{"webhook_url":"https://example.com/x"}]}`, true},
|
||||
{"missing webhook_url", `{"name":"gc","googlechat_configs":[{"title":"x"}]}`, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -13,22 +15,9 @@ type ChannelIncidentIOConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true"`
|
||||
Token string `json:"token" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Metadata map[string]string `json:"metadata,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelIncidentIOConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelIncidentIOConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(DefaultIncidentIOReceiverConfig.Title)
|
||||
c.Description.SetIfUnset(DefaultIncidentIOReceiverConfig.Description)
|
||||
|
||||
return c.Validate()
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) Validate() error {
|
||||
@@ -75,6 +64,11 @@ func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (Ch
|
||||
}, nil
|
||||
}
|
||||
|
||||
// incidentIOEventsPathPrefix is the path of incident.io's HTTP alert source
|
||||
// endpoint (Alert Events V2 API). The full URL is per-source:
|
||||
// https://api.incident.io/v2/alert_events/http/<source_config_id>.
|
||||
const incidentIOEventsPathPrefix = "/v2/alert_events/http/"
|
||||
|
||||
// The description is markdown; incident.io renders it natively. The templates
|
||||
// mirror Google Chat / Jira / JSM for a consistent default across channels.
|
||||
const (
|
||||
@@ -138,8 +132,19 @@ func (c *IncidentIOReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
|
||||
c.Description = DefaultIncidentIODescriptionTemplate
|
||||
}
|
||||
|
||||
if c.URL == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url is required")
|
||||
// Values are stored and sent exactly as configured, so anything that is
|
||||
// not already canonical is rejected rather than rewritten.
|
||||
if c.URL != strings.TrimSpace(c.URL) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url must not have leading or trailing whitespace")
|
||||
}
|
||||
u, err := url.Parse(c.URL)
|
||||
if c.URL == "" || err != nil || u.Scheme != "https" || u.Host == "" ||
|
||||
!strings.Contains(u.Path, incidentIOEventsPathPrefix) ||
|
||||
strings.HasSuffix(u.Path, incidentIOEventsPathPrefix) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("incidentio url must be an alert events URL (https://api.incident.io%s<source_config_id>)", incidentIOEventsPathPrefix))
|
||||
}
|
||||
if strings.HasSuffix(c.URL, "/") {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url must not end with a trailing slash")
|
||||
}
|
||||
|
||||
token := string(c.Token)
|
||||
|
||||
@@ -45,6 +45,11 @@ func TestIncidentIOReceiverConfigValidation(t *testing.T) {
|
||||
json string
|
||||
}{
|
||||
{"missing url", `{"name":"incio","incidentio_configs":[{"token":"k"}]}`},
|
||||
{"http url", `{"name":"incio","incidentio_configs":[{"url":"http://api.incident.io/v2/alert_events/http/abc","token":"k"}]}`},
|
||||
{"not an alert events url", `{"name":"incio","incidentio_configs":[{"url":"https://api.incident.io/v2/incidents","token":"k"}]}`},
|
||||
{"missing source config id", `{"name":"incio","incidentio_configs":[{"url":"https://api.incident.io/v2/alert_events/http/","token":"k"}]}`},
|
||||
{"trailing slash", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s/","token":"k"}]}`, testIncidentIOURL)},
|
||||
{"whitespace around url", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":" %s ","token":"k"}]}`, testIncidentIOURL)},
|
||||
{"missing token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s"}]}`, testIncidentIOURL)},
|
||||
{"bearer prefixed token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s","token":"Bearer tok-123"}]}`, testIncidentIOURL)},
|
||||
{"lowercase bearer prefixed token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s","token":"bearer tok-123"}]}`, testIncidentIOURL)},
|
||||
|
||||
@@ -2,6 +2,7 @@ package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,35 +20,20 @@ type ChannelJiraConfig struct {
|
||||
Site string `json:"site" required:"true"`
|
||||
Project string `json:"project" required:"true"`
|
||||
IssueType string `json:"issueType" required:"true"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Labels []string `json:"labels,omitzero"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
ResolveTransition string `json:"resolveTransition"`
|
||||
ReopenTransition string `json:"reopenTransition"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
|
||||
WontFixResolution string `json:"wontFixResolution"`
|
||||
CustomFields map[string]any `json:"customFields,omitzero"`
|
||||
CustomFields map[string]any `json:"customFields,omitempty"`
|
||||
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
|
||||
func (c *ChannelJiraConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJiraConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, false)
|
||||
c.Summary.SetIfUnset(DefaultJiraSummaryTemplate)
|
||||
c.Description.SetIfUnset(DefaultJiraDescriptionTemplate)
|
||||
c.ReopenDuration.SetIfUnset(defaultJiraReopenDuration.String())
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) Validate() error {
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
@@ -167,6 +153,7 @@ const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
|
||||
// Service accounts authenticate against the api.atlassian.com gateway (keyed by
|
||||
// cloud id) instead of the site host; they are identified by their email domain.
|
||||
const (
|
||||
jiraCloudHostSuffix = ".atlassian.net"
|
||||
jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com"
|
||||
jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/"
|
||||
)
|
||||
@@ -217,13 +204,11 @@ func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
if c.ReopenDuration <= 0 {
|
||||
c.ReopenDuration = defaultJiraReopenDuration
|
||||
}
|
||||
|
||||
// sub-minute windows truncate to 0 in the reopen JQL and silently disable
|
||||
// reopening, so reject them.
|
||||
if c.ReopenDuration < model.Duration(time.Minute) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m")
|
||||
}
|
||||
|
||||
if c.Summary == "" {
|
||||
c.Summary = DefaultJiraSummaryTemplate
|
||||
}
|
||||
@@ -231,9 +216,19 @@ func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
c.Description = DefaultJiraDescriptionTemplate
|
||||
}
|
||||
|
||||
if c.Site == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site is required")
|
||||
// Values are stored and sent exactly as configured, so anything that is
|
||||
// not already canonical is rejected rather than rewritten.
|
||||
if c.Site != strings.TrimSpace(c.Site) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site must not have leading or trailing whitespace")
|
||||
}
|
||||
u, err := url.Parse(c.Site)
|
||||
if c.Site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://<site>%s)", jiraCloudHostSuffix))
|
||||
}
|
||||
if strings.HasSuffix(c.Site, "/") {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site must not end with a trailing slash")
|
||||
}
|
||||
|
||||
if c.Project == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required")
|
||||
}
|
||||
|
||||
@@ -11,19 +11,19 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jiraReceiverJSON(project, issueType string, withAuth bool) string {
|
||||
func jiraReceiverJSON(site, project, issueType string, withAuth bool) string {
|
||||
auth := ""
|
||||
if withAuth {
|
||||
auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}`
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`{"name":"jira","jira_configs":[{"site":"https://acme.atlassian.net","project":%q,"issue_type":%q%s}]}`,
|
||||
project, issueType, auth,
|
||||
`{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`,
|
||||
site, project, issueType, auth,
|
||||
)
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigDefaults(t *testing.T) {
|
||||
r, err := NewReceiver(jiraReceiverJSON("KAN", "Task", true))
|
||||
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JiraConfigs, 1)
|
||||
|
||||
@@ -93,10 +93,17 @@ func TestJiraReceiverConfigValidation(t *testing.T) {
|
||||
json string
|
||||
}{
|
||||
{"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
{"missing project", jiraReceiverJSON("", "Task", true)},
|
||||
{"missing issue_type", jiraReceiverJSON("KAN", "", true)},
|
||||
{"missing basic auth", jiraReceiverJSON("KAN", "Task", false)},
|
||||
{"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)},
|
||||
{"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)},
|
||||
{"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)},
|
||||
{"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)},
|
||||
{"trailing slash site", jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true)},
|
||||
{"padded site", jiraReceiverJSON(" https://acme.atlassian.net ", "KAN", "Task", true)},
|
||||
{"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)},
|
||||
{"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)},
|
||||
{"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)},
|
||||
{"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
{"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -12,25 +12,11 @@ import (
|
||||
type ChannelJSMOpsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
// Tags is the comma-separated list JSM Ops attaches to the alert.
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJSMOpsConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(DefaultJSMOpsReceiverConfig.Message)
|
||||
c.Description.SetIfUnset(DefaultJSMOpsReceiverConfig.Description)
|
||||
c.Tags.SetIfUnset(DefaultJSMOpsReceiverConfig.Tags)
|
||||
|
||||
return c.Validate()
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) Validate() error {
|
||||
|
||||
@@ -9,21 +9,8 @@ import (
|
||||
type ChannelMSTeamsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelMSTeamsConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)
|
||||
c.Title.SetIfUnset(config.DefaultMSTeamsV2Config.Title)
|
||||
c.Text.SetIfUnset(config.DefaultMSTeamsV2Config.Text)
|
||||
|
||||
return c.Validate()
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) Validate() error {
|
||||
|
||||
@@ -10,27 +10,13 @@ type ChannelOpsgenieConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
|
||||
Details map[string]string `json:"details,omitzero"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelOpsgenieConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(config.DefaultOpsGenieConfig.Message)
|
||||
c.Description.SetIfUnset(config.DefaultOpsGenieConfig.Description)
|
||||
c.Source.SetIfUnset(config.DefaultOpsGenieConfig.Source)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
|
||||
|
||||
@@ -10,39 +10,15 @@ type ChannelPagerdutyConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
RoutingKey string `json:"routingKey" required:"true" format:"password"`
|
||||
URL string `json:"url"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Component string `json:"component"`
|
||||
Group string `json:"group"`
|
||||
Class string `json:"class"`
|
||||
Details map[string]string `json:"details,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelPagerdutyConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)
|
||||
c.Description.SetIfUnset(config.DefaultPagerdutyConfig.Description)
|
||||
c.Client.SetIfUnset(config.DefaultPagerdutyConfig.Client)
|
||||
c.ClientURL.SetIfUnset(config.DefaultPagerdutyConfig.ClientURL)
|
||||
c.Source.SetIfUnset(c.Client.StringValue())
|
||||
|
||||
if c.Details == nil {
|
||||
c.Details = make(map[string]string, len(config.DefaultPagerdutyDetails))
|
||||
for key, value := range config.DefaultPagerdutyDetails {
|
||||
if template, ok := value.(string); ok {
|
||||
c.Details[key] = template
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Validate()
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) Validate() error {
|
||||
@@ -114,32 +90,3 @@ func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (Cha
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newUpstreamDetails(details map[string]string) map[string]any {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
upstream[key] = value
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func extractStringDetails(name string, details map[string]any) (map[string]string, error) {
|
||||
extracted := make(map[string]string, len(details))
|
||||
for key, value := range details {
|
||||
stringValue, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets a non-string value for details.%s, which is not supported", name, key,
|
||||
)
|
||||
}
|
||||
extracted[key] = stringValue
|
||||
}
|
||||
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ type ChannelSlackConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password"`
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero"`
|
||||
Fields []ChannelSlackField `json:"fields,omitzero"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitzero"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
|
||||
Fields []ChannelSlackField `json:"fields,omitempty"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
@@ -46,24 +46,6 @@ type ChannelSlackConfirmation struct {
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c *ChannelSlackConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelSlackConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultSlackConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(config.DefaultSlackConfig.Title)
|
||||
c.Text.SetIfUnset(config.DefaultSlackConfig.Text)
|
||||
c.Color.SetIfUnset(config.DefaultSlackConfig.Color)
|
||||
c.TitleLink.SetIfUnset(config.DefaultSlackConfig.TitleLink)
|
||||
c.Pretext.SetIfUnset(config.DefaultSlackConfig.Pretext)
|
||||
c.Fallback.SetIfUnset(config.DefaultSlackConfig.Fallback)
|
||||
c.Footer.SetIfUnset(config.DefaultSlackConfig.Footer)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) Validate() error {
|
||||
if c.APIURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// bearerAuthorizationType is the scheme SigNoz writes for token auth.
|
||||
const bearerAuthorizationType = "Bearer"
|
||||
|
||||
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
|
||||
// overloaded onto one password field, where an empty username meant the password
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
@@ -23,17 +18,6 @@ type ChannelWebhookConfig struct {
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
}
|
||||
|
||||
func (c *ChannelWebhookConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelWebhookConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultWebhookConfig.VSendResolved)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
|
||||
@@ -112,16 +96,3 @@ func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (Chann
|
||||
|
||||
return webhook, nil
|
||||
}
|
||||
|
||||
func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.Authorization == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,50 +1,104 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeChannelUnsupportedKind = errors.MustNewCode("channel_unsupported_kind")
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Kind
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ChannelKind selects which ChannelSpec a channel carries and which notifier
|
||||
// integration is built for it.
|
||||
type ChannelKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ChannelKindSlack = ChannelKind{valuer.NewString("slack")}
|
||||
ChannelKindEmail = ChannelKind{valuer.NewString("email")}
|
||||
ChannelKindWebhook = ChannelKind{valuer.NewString("webhook")}
|
||||
ChannelKindPagerduty = ChannelKind{valuer.NewString("pagerduty")}
|
||||
ChannelKindOpsgenie = ChannelKind{valuer.NewString("opsgenie")}
|
||||
ChannelKindMSTeams = ChannelKind{valuer.NewString("msteams")}
|
||||
ChannelKindGoogleChat = ChannelKind{valuer.NewString("googlechat")}
|
||||
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
|
||||
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
|
||||
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
|
||||
)
|
||||
|
||||
func (ChannelKind) Enum() []any {
|
||||
kinds := make([]any, 0, len(channelKinds))
|
||||
for _, channelKind := range channelKinds {
|
||||
kinds = append(kinds, channelKind.kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func (t ChannelKind) IsValid() bool {
|
||||
return slices.ContainsFunc(t.Enum(), func(v any) bool { return v == t })
|
||||
}
|
||||
|
||||
// ToStoredType returns the Channel.Type a channel of this kind is stored under,
|
||||
// which matches the kind for all but msteams.
|
||||
func (t ChannelKind) ToStoredType() string {
|
||||
if t == ChannelKindMSTeams {
|
||||
return "msteamsv2"
|
||||
}
|
||||
|
||||
return t.StringValue()
|
||||
}
|
||||
|
||||
// parseStoredChannelType inverts ToStoredType. It reports false for the notifier
|
||||
// kinds v1 accepted but v2 does not model.
|
||||
func parseStoredChannelType(stored string) (ChannelKind, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind.ToStoredType() == stored {
|
||||
return channelKind.kind, true
|
||||
}
|
||||
}
|
||||
|
||||
return ChannelKind{}, false
|
||||
}
|
||||
|
||||
func ErrUnsupportedChannelKind(s string) error {
|
||||
return errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
ErrCodeChannelUnsupportedKind,
|
||||
"unknown notification channel kind %q; allowed values: %s",
|
||||
s, allowedValuesForChannelKind(),
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Union
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ChannelConfig is the discriminated union of per-kind configurations. The
|
||||
// envelope sits on config rather than the resource root, so clients narrow on
|
||||
// config.kind instead of every request and response flavor becoming a oneOf.
|
||||
type ChannelConfig struct {
|
||||
Kind ChannelKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (c *ChannelConfig) UnmarshalJSON(data []byte) error {
|
||||
var envelope struct {
|
||||
Kind ChannelKind `json:"kind"`
|
||||
|
||||
// json.RawMessage keeps spec bytes unparsed until Kind is known. Once
|
||||
// Kind is known, this Spec can be decoded into the correct type.
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
if err := decodeStrict(data, &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(envelope.Spec) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec is required")
|
||||
}
|
||||
|
||||
spec, ok := buildEmptyChannelSpecForKind(envelope.Kind)
|
||||
if !ok {
|
||||
return ErrUnsupportedChannelKind(envelope.Kind.StringValue())
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(envelope.Spec, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Kind = envelope.Kind
|
||||
c.Spec = spec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelConfig) Validate() error {
|
||||
expected, ok := buildEmptyChannelSpecForKind(c.Kind)
|
||||
newSpec, ok := newChannelSpec(c.Kind)
|
||||
if !ok {
|
||||
return ErrUnsupportedChannelKind(c.Kind.StringValue())
|
||||
}
|
||||
@@ -53,21 +107,44 @@ func (c ChannelConfig) Validate() error {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec is required")
|
||||
}
|
||||
|
||||
spec, err := c.toChannelSpec()
|
||||
if err != nil {
|
||||
return err
|
||||
spec, ok := c.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
// Only a config assembled in Go gets here, and one can pair a spec with the
|
||||
// wrong kind. The conversion to a receiver dispatches on the spec, so the
|
||||
// mismatch would silently outrank the declared kind.
|
||||
if reflect.TypeOf(spec) != reflect.TypeOf(expected) {
|
||||
// A decoded config cannot disagree, because UnmarshalJSON builds the spec
|
||||
// from the kind. A caller assembling the struct can, and the conversion to a
|
||||
// receiver dispatches on the spec, so a mismatch would silently outrank the
|
||||
// declared kind.
|
||||
if reflect.TypeOf(spec) != reflect.TypeOf(newSpec()) {
|
||||
return errors.NewInternalf(errors.CodeInternal, "config.spec does not match kind %q", c.Kind.StringValue())
|
||||
}
|
||||
|
||||
return spec.Validate()
|
||||
}
|
||||
|
||||
func (c *ChannelConfig) UnmarshalJSON(data []byte) error {
|
||||
channelKindString, specJSON, err := extractKindAndSpec(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
factory, ok := newChannelSpec(ChannelKind{valuer.NewString(channelKindString)})
|
||||
if !ok {
|
||||
return ErrUnsupportedChannelKind(channelKindString)
|
||||
}
|
||||
|
||||
spec, err := decodeChannelSpec(specJSON, factory(), channelKindString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Kind = ChannelKind{valuer.NewString(channelKindString)}
|
||||
c.Spec = *spec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelConfigVariant names one branch of the union. Each instantiation becomes
|
||||
// its own OpenAPI component with kind pinned to the one value it accepts.
|
||||
type ChannelConfigVariant[S any] struct {
|
||||
@@ -116,6 +193,311 @@ func (ChannelConfig) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Specs
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type ChannelSpec interface {
|
||||
Validate() error
|
||||
toUndefaultedReceiver(displayName string) (*Receiver, error)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// bearerAuthorizationType is the scheme SigNoz writes for token auth.
|
||||
const bearerAuthorizationType = "Bearer"
|
||||
|
||||
// parseSecretURL and parseUpstreamURL wrap the two URL types upstream uses for
|
||||
// notifier endpoints. Callers holding an optional URL skip the call on an empty
|
||||
// string, so the field stays nil and is omitted rather than stored as an empty URL.
|
||||
func parseSecretURL(raw string) (*config.SecretURL, error) {
|
||||
parsed, err := parseUpstreamURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return (*config.SecretURL)(parsed), nil
|
||||
}
|
||||
|
||||
func parseUpstreamURL(raw string) (*config.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse url %q", raw)
|
||||
}
|
||||
|
||||
return &config.URL{URL: parsed}, nil
|
||||
}
|
||||
|
||||
func formatSecretURL(secretURL *config.SecretURL) string {
|
||||
if secretURL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatUpstreamURL((*config.URL)(secretURL))
|
||||
}
|
||||
|
||||
func formatUpstreamURL(upstreamURL *config.URL) string {
|
||||
if upstreamURL == nil || upstreamURL.URL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return upstreamURL.String()
|
||||
}
|
||||
|
||||
// PagerDuty is the one notifier whose details upstream types as map[string]any.
|
||||
func newUpstreamDetails(details map[string]string) map[string]any {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
upstream[key] = value
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func extractStringDetails(name string, details map[string]any) (map[string]string, error) {
|
||||
extracted := make(map[string]string, len(details))
|
||||
for key, value := range details {
|
||||
stringValue, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets a non-string value for details.%s, which is not supported", name, key,
|
||||
)
|
||||
}
|
||||
extracted[key] = stringValue
|
||||
}
|
||||
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuth(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if httpConfig.BasicAuth != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
}
|
||||
|
||||
if httpConfig.Authorization != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfig(channelName, httpConfig)
|
||||
}
|
||||
|
||||
func rejectUnsupportedHTTPConfig(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
fieldName string
|
||||
isFieldConfigured bool
|
||||
}{
|
||||
{"oauth2", httpConfig.OAuth2 != nil},
|
||||
{"bearer_token", httpConfig.BearerToken != ""},
|
||||
{"bearer_token_file", httpConfig.BearerTokenFile != ""},
|
||||
{"proxy_url", httpConfig.ProxyURL.URL != nil && httpConfig.ProxyURL.String() != ""},
|
||||
{"no_proxy", httpConfig.NoProxy != ""},
|
||||
{"proxy_from_environment", httpConfig.ProxyFromEnvironment},
|
||||
{"http_headers", httpConfig.HTTPHeaders != nil},
|
||||
{"tls_config", httpConfig.TLSConfig != (commoncfg.TLSConfig{})},
|
||||
{"follow_redirects", !httpConfig.FollowRedirects},
|
||||
{"enable_http2", !httpConfig.EnableHTTP2},
|
||||
} {
|
||||
if field.isFieldConfigured {
|
||||
return errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets http_config.%s, which is not supported", channelName, field.fieldName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.BasicAuth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.Authorization == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// channelKinds registers each notification kind with the spec constructor
|
||||
// UnmarshalJSON picks by kind and the extractor that reads a stored receiver
|
||||
// back. The ChannelKind enum derives from it; the JSON schema hooks stay
|
||||
// literal lists so each branch reads as one line.
|
||||
var channelKinds = []channelKindEntry{
|
||||
{
|
||||
kind: ChannelKindSlack,
|
||||
newSpec: func() ChannelSpec { return new(ChannelSlackConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.SlackConfigs) },
|
||||
extractSpec: newChannelSlackConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindEmail,
|
||||
newSpec: func() ChannelSpec { return new(ChannelEmailConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.EmailConfigs) },
|
||||
extractSpec: newChannelEmailConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindWebhook,
|
||||
newSpec: func() ChannelSpec { return new(ChannelWebhookConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.WebhookConfigs) },
|
||||
extractSpec: newChannelWebhookConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindPagerduty,
|
||||
newSpec: func() ChannelSpec { return new(ChannelPagerdutyConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.PagerdutyConfigs) },
|
||||
extractSpec: newChannelPagerdutyConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindOpsgenie,
|
||||
newSpec: func() ChannelSpec { return new(ChannelOpsgenieConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.OpsGenieConfigs) },
|
||||
extractSpec: newChannelOpsgenieConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindMSTeams,
|
||||
newSpec: func() ChannelSpec { return new(ChannelMSTeamsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.MSTeamsV2Configs) },
|
||||
extractSpec: newChannelMSTeamsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindGoogleChat,
|
||||
newSpec: func() ChannelSpec { return new(ChannelGoogleChatConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.GoogleChatConfigs) },
|
||||
extractSpec: newChannelGoogleChatConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJira,
|
||||
newSpec: func() ChannelSpec { return new(ChannelJiraConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JiraConfigs) },
|
||||
extractSpec: newChannelJiraConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJSMOps,
|
||||
newSpec: func() ChannelSpec { return new(ChannelJSMOpsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JSMOpsConfigs) },
|
||||
extractSpec: newChannelJSMOpsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindIncidentIO,
|
||||
newSpec: func() ChannelSpec { return new(ChannelIncidentIOConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
|
||||
extractSpec: newChannelIncidentIOConfigFromReceiver,
|
||||
},
|
||||
}
|
||||
|
||||
type channelKindEntry struct {
|
||||
kind ChannelKind
|
||||
newSpec func() ChannelSpec
|
||||
// countConfigs guards extractSpec, which reads the receiver's first config of
|
||||
// this kind and so must not be called when there is none.
|
||||
countConfigs func(receiver *Receiver) int
|
||||
extractSpec func(name string, receiver *Receiver) (ChannelSpec, error)
|
||||
}
|
||||
|
||||
func newChannelSpec(kind ChannelKind) (func() ChannelSpec, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind == kind {
|
||||
return channelKind.newSpec, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// resolveSendResolved falls back to the notifier's own upstream default, because
|
||||
// send_resolved has no omitempty: a zero value would marshal as an explicit false
|
||||
// and overwrite the default rather than leave it in place.
|
||||
func resolveSendResolved(sendResolved *bool, upstreamDefault bool) bool {
|
||||
if sendResolved == nil {
|
||||
return upstreamDefault
|
||||
}
|
||||
|
||||
return *sendResolved
|
||||
}
|
||||
|
||||
func allowedValuesForChannelKind() string {
|
||||
return formatAllowedValues((ChannelKind{}).Enum())
|
||||
}
|
||||
|
||||
func formatAllowedValues(enum []any) string {
|
||||
values := make([]string, 0, len(enum))
|
||||
for _, value := range enum {
|
||||
stringValuer, ok := value.(interface{ StringValue() string })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
values = append(values, "`"+stringValuer.StringValue()+"`")
|
||||
}
|
||||
slices.Sort(values)
|
||||
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
// extractKindAndSpec parses a {"kind": "...", "spec": {...}} envelope. Unknown
|
||||
// keys are rejected here rather than by the caller's decoder: a custom
|
||||
// UnmarshalJSON receives raw bytes, so DisallowUnknownFields on the request body
|
||||
// does not reach inside config.
|
||||
func extractKindAndSpec(data []byte) (string, []byte, error) {
|
||||
var head struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&head); err != nil {
|
||||
return "", nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "invalid channel config envelope")
|
||||
}
|
||||
return head.Kind, head.Spec, nil
|
||||
}
|
||||
|
||||
// decodeChannelSpec rejects unknown fields so a spec meant for another kind is an
|
||||
// error rather than a silently empty struct, and validates before returning.
|
||||
func decodeChannelSpec[T ChannelSpec](specJSON []byte, target T, channelType string) (*T, error) {
|
||||
if len(specJSON) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "type %q: spec is required", channelType)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(specJSON))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "type %q: invalid spec JSON", channelType)
|
||||
}
|
||||
if err := target.Validate(); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "type %q: %s", channelType, err.Error())
|
||||
}
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators
|
||||
// promotes into a native OpenAPI 3 discriminator after reflection.
|
||||
const signozDiscriminatorKey = "x-signoz-discriminator"
|
||||
@@ -132,18 +514,6 @@ func channelVariantRef(spec string) string {
|
||||
return schemaRef("AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypes" + spec)
|
||||
}
|
||||
|
||||
// toChannelSpec asserts what UnmarshalJSON decoded. Spec is any rather than
|
||||
// ChannelSpec because the OpenAPI reflector turns an interface field into an
|
||||
// empty component.
|
||||
func (c ChannelConfig) toChannelSpec() (ChannelSpec, error) {
|
||||
spec, ok := c.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// markDiscriminator tags a oneOf schema with x-signoz-discriminator, keyed on
|
||||
// propertyName with the given value -> schema-ref mapping, so generated clients
|
||||
// get a discriminated DTO instead of an intersection.
|
||||
|
||||
@@ -3,11 +3,8 @@ package alertmanagertypes
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
@@ -15,47 +12,13 @@ import (
|
||||
// API -> storage
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ToChannel returns the receiver alongside because the alertmanager config is
|
||||
// updated from it, not from the channel.
|
||||
func (p *PostableNotificationChannel) ToChannel(orgID string) (*Channel, *Receiver, error) {
|
||||
receiver, err := p.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal receiver")
|
||||
}
|
||||
|
||||
spec, err := p.Config.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
channel := &Channel{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
Type: p.Config.Kind.StringValue(),
|
||||
Data: string(data),
|
||||
OrgID: orgID,
|
||||
}
|
||||
if err := channel.fillSpec(spec); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return channel, receiver, nil
|
||||
}
|
||||
|
||||
// ToReceiver hands the assembled receiver to newDefaultedReceiver, which is the
|
||||
// only place upstream applies a notifier's defaults and validation — several
|
||||
// integrations panic without them.
|
||||
func (p *PostableNotificationChannel) ToReceiver() (*Receiver, error) {
|
||||
spec, err := p.Config.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
spec, ok := p.Config.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
receiver, err := spec.toUndefaultedReceiver(p.DisplayName)
|
||||
@@ -82,63 +45,22 @@ func (t *TestableNotificationChannel) ToReceiver() (*Receiver, error) {
|
||||
return postable.ToReceiver()
|
||||
}
|
||||
|
||||
func (c *Channel) UpdateFromUpdatable(updatable UpdatableNotificationChannel) (*Receiver, error) {
|
||||
receiver, err := updatable.ToReceiver(c.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal receiver")
|
||||
}
|
||||
|
||||
spec, err := updatable.Config.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.fillSpec(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Type = updatable.Config.Kind.StringValue()
|
||||
c.Data = string(data)
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Storage -> API
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// toChannelConfig pairs the scanned spec with the kind Type names. Only a row
|
||||
// the migration could not backfill has none, so deriving it here reports why.
|
||||
func (c *Channel) toChannelConfig() (ChannelConfig, error) {
|
||||
if c.Spec == nil {
|
||||
return c.deriveChannelConfig()
|
||||
}
|
||||
|
||||
channelKind, ok := parseChannelKind(c.Type)
|
||||
if !ok {
|
||||
return ChannelConfig{}, errors.NewInternalf(errors.CodeInternal, "channel %q carries a spec under unmodelled type %q", c.DisplayName, c.Type)
|
||||
}
|
||||
|
||||
return ChannelConfig{Kind: channelKind, Spec: c.Spec}, nil
|
||||
}
|
||||
|
||||
// deriveChannelConfig derives the kind from the config the receiver actually
|
||||
// carries rather than from Channel.Type, so a row written with several notifier
|
||||
// kinds is rejected instead of reported under whichever one receiverChannelType
|
||||
// happened to pick.
|
||||
func (c *Channel) deriveChannelConfig() (ChannelConfig, error) {
|
||||
// toPostableNotificationChannel derives the kind from the config the receiver
|
||||
// actually carries rather than from Channel.Type, so a row written with several
|
||||
// notifier kinds is rejected instead of reported under whichever one
|
||||
// receiverChannelType happened to pick.
|
||||
func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel, error) {
|
||||
receiver := &Receiver{Receiver: &config.Receiver{}}
|
||||
if err := json.Unmarshal([]byte(c.Data), receiver); err != nil {
|
||||
return ChannelConfig{}, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
}
|
||||
|
||||
if total := countNotifierConfigs(receiver); total > 1 {
|
||||
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
|
||||
}
|
||||
|
||||
for _, channelKind := range channelKinds {
|
||||
@@ -148,21 +70,17 @@ func (c *Channel) deriveChannelConfig() (ChannelConfig, error) {
|
||||
|
||||
spec, err := channelKind.extractSpec(c.DisplayName, receiver)
|
||||
if err != nil {
|
||||
return ChannelConfig{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The derived config is stored and decoded back through the same
|
||||
// validation a request goes through, so one that would not decode is
|
||||
// unrepresentable rather than stored.
|
||||
channelConfig := ChannelConfig{Kind: channelKind.kind, Spec: spec}
|
||||
if err := channelConfig.Validate(); err != nil {
|
||||
return ChannelConfig{}, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "channel %q: %s", c.DisplayName, err.Error())
|
||||
}
|
||||
|
||||
return channelConfig, nil
|
||||
return &PostableNotificationChannel{
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: ChannelConfig{Kind: channelKind.kind, Spec: spec},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
|
||||
return nil, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
|
||||
}
|
||||
|
||||
// countNotifierConfigs totals every *_configs entry on the receiver, including
|
||||
@@ -193,15 +111,15 @@ func countConfigsFields(v reflect.Value) int {
|
||||
}
|
||||
|
||||
func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel, error) {
|
||||
channelConfig, err := c.toChannelConfig()
|
||||
postable, err := c.toPostableNotificationChannel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GettableNotificationChannel{
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: channelConfig,
|
||||
Name: postable.Name,
|
||||
DisplayName: postable.DisplayName,
|
||||
Config: postable.Config,
|
||||
ID: c.ID,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
@@ -212,7 +130,7 @@ func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel,
|
||||
// no ChannelKind models, which v1 allowed because it accepted every upstream
|
||||
// notifier kind. One such row must not fail the whole page.
|
||||
func (c *Channel) ToListedNotificationChannel() *ListedNotificationChannel {
|
||||
channelKind, _ := parseChannelKind(c.Type)
|
||||
channelKind, _ := parseStoredChannelType(c.Type)
|
||||
|
||||
return &ListedNotificationChannel{
|
||||
ID: c.ID,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
// field fails rather than going unasserted. Webhook is covered by
|
||||
// TestPostableChannelToReceiverRoundTripsWebhookAuthModes, whose auth modes are
|
||||
// mutually exclusive and so cannot all be set at once.
|
||||
func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
sendResolved := true
|
||||
short := true
|
||||
|
||||
@@ -269,14 +270,18 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, testCase.kind, derived.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, postable.Name, roundTripped.Name)
|
||||
assert.Equal(t, testCase.kind, roundTripped.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -294,7 +299,10 @@ func TestPostableChannelToReceiverOmitsEmailTransportCredentials(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, credentialKey := range []string{"auth_username", "auth_password", "auth_secret", "tls_config"} {
|
||||
@@ -346,17 +354,40 @@ func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, channel.Data, testCase.expectedInData)
|
||||
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The SigNoz notifiers validate in their UnmarshalYAML, which ToReceiver reaches
|
||||
// only through the defaulting round-trip. A spec that passes Validate can still
|
||||
// be rejected there, and the request has to fail as invalid input rather than as
|
||||
// an internal error.
|
||||
func TestPostableChannelToReceiverReportsNotifierValidationAsInvalidInput(t *testing.T) {
|
||||
postable := PostableNotificationChannel{
|
||||
Name: "channel",
|
||||
DisplayName: "channel",
|
||||
Config: ChannelConfig{Kind: ChannelKindIncidentIO, Spec: &ChannelIncidentIOConfig{
|
||||
URL: "https://api.incident.io/v2/incidents", Token: "token",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"), Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
|
||||
}},
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
_, err := postable.ToReceiver()
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "got %v", err)
|
||||
}
|
||||
|
||||
// rejectUnsupportedHTTPConfig enumerates the fields it rejects, so one added
|
||||
// upstream would pass unnoticed and be dropped on read. Pinning the counts turns
|
||||
// a dependency bump into a failing test rather than silent data loss.
|
||||
@@ -365,7 +396,7 @@ func TestRejectUnrepresentableHTTPConfigCoversEveryUpstreamMember(t *testing.T)
|
||||
assert.Equal(t, 5, reflect.TypeFor[commoncfg.ProxyConfig]().NumField())
|
||||
}
|
||||
|
||||
func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
channel Channel
|
||||
@@ -527,7 +558,7 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
_, err := testCase.channel.deriveChannelConfig()
|
||||
_, err := testCase.channel.toPostableNotificationChannel()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -535,7 +566,7 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
|
||||
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
|
||||
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
|
||||
func TestDeriveChannelConfigReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
@@ -564,10 +595,10 @@ func TestDeriveChannelConfigReadsWebhookBearerSchemeCaseInsensitively(t *testing
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
|
||||
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
postable, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ChannelKindWebhook, derived.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, derived.Spec)
|
||||
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user