Compare commits

...

26 Commits

Author SHA1 Message Date
vikrantgupta25
2a3aef7b48 Merge remote-tracking branch 'origin/main' into keystone-pod/issues/38
# Conflicts:
#	pkg/http/handler/resourcedef.go
2026-09-30 19:47:03 +05:30
Vikrant Gupta
e374d03e54 revert(authz): restore gjson-based body extraction in resource middleware (#13024)
Some checks are pending
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Reverts #13014 and #13015. The resource middleware goes back to
reading body-derived resource ids with `BodyJSONPath` / `BodyJSONArray`
over the raw body, and handlers decode their own request bodies again.
- Authz should not own request decoding; that ownership stays with the
handlers.

#### Additional Information

- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-30 13:53:21 +00:00
vikrantgupta25
d08cd99107 refactor(user): drop self-mutation guards from admin routes 2026-09-30 18:43:18 +05:30
vikrantgupta25
507ea382cf refactor(authz): read user route body ids with the gjson extractors 2026-09-30 18:43:16 +05:30
vikrantgupta25
58acd6d337 Merge remote-tracking branch 'origin/revert/authz-body-decoding' into keystone-pod/issues/38 2026-09-30 18:41:22 +05:30
vikrantgupta25
e4afc8832b revert(authz): decode the request body once in the resource middleware
This reverts commit 8d80f98710 (#13014).
Authz should not own request body decoding.
2026-09-30 18:31:04 +05:30
vikrantgupta25
585a3b6140 revert(authz): read every body-derived resource id from the decoded request
This reverts commit 8dba9a13ea (#13015).
Authz should not own request body decoding.
2026-09-30 18:31:03 +05:30
vikrantgupta25
6a1fac8118 chore(openapi): regenerate spec for reset password token read 2026-09-30 18:21:05 +05:30
vikrantgupta25
b8869e7005 fix(authz): check only factor-password list when reading a reset password token 2026-09-30 18:20:29 +05:30
vikrantgupta25
2a5647f6b8 Merge remote-tracking branch 'origin/main' into keystone-pod/issues/38
# Conflicts:
#	pkg/signoz/provider.go
2026-09-30 18:20:01 +05:30
vikrantgupta25
ed45d5054d chore(openapi): regenerate spec for users by role scope 2026-09-30 18:17:41 +05:30
vikrantgupta25
057b61fe68 fix(authz): check only role read when listing users by role 2026-09-30 18:17:39 +05:30
Swapnil Nakade
d445b6c296 chore: bumping cloud integration agent version to v0.0.15 (#13023)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Bumping the cloud integration agent's version to latest v0.0.15

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/101
2026-09-30 12:38:24 +00:00
vikrantgupta25
643de2055d chore(openapi): regenerate spec for reset password token scope 2026-09-30 17:23:54 +05:30
vikrantgupta25
86d9dae524 fix(authz): check factor-password list when reading a reset password token 2026-09-30 17:23:52 +05:30
Swapnil Nakade
fd8aaac300 feat: adding sync state in cloud integration (#12991)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
The agent only saw the current list of enabled regions, so it couldn’t
tell which regions had been removed. To find stacks to clean up, it
checked unrelated AWS regions, causing unnecessary calls and permission
errors. Sync state keeps track of regions sent to the agent and pending
removals until the agent acknowledges cleanup.

Please check
[comment](https://github.com/SigNoz/keystone-pod/issues/101#issuecomment-5832865898)
for approach

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/101

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
This PR should be merged before changes for cloud-integration repo.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-30 11:39:46 +00:00
vikrantgupta25
7f3ec73dfd fix(authz): guard nil request in user role extractors 2026-09-30 17:06:58 +05:30
Naman Verma
e4cd8dbf10 chore: store v2 config for notification channels in db (#12984)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

1. add a config column to notification channels table where the channel
config goes without dealing with receiver at all. this helps in cleaning
up all round trip issues caused by dealing with receiver in the storage
layer. v2 apis treat receiver as a side effect now
1a. for applicable fields, defaults are filled in create/update api if
fields are omitted
1b. explicit [] and {} are no longer dropped, and omitted [] and {} are
returned as explicit null
2. add integration tests for all notification channel round trip issues
3. code cleanup of v2 channels types 
4. migration to fill the config column from receiver column, which logs
results like dashboards migration did
4a. it fails for receivers that cannot be modeled in v2. repair api can
be used for them
4b. for types that v2 supports, any fields that v1 supports but v2
doesn't, this migration drops those fields
5. make v1 API reject anything that v2 apis do not support, and also
fill the new config column added
6. add integration tests for v1<>v2 interaction to ensure that alert
manager doesn't break because of new changes added

What breaks/changes for v1:
1. types that v2 does not support can no longer be created
2. channels with multiple receivers cannot be created
3. channels with fields that v2 does not support cannot be created
4. existing channels of types that v2 does not support can no longer be
edited via v1 API. They can be deleted though. Also, they keep on
sending notifications as before (sigh).

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/376
Closes https://github.com/SigNoz/pulse-pod/issues/378
2026-09-30 11:30:33 +00:00
vikrantgupta25
a39eec0f5c chore(openapi): regenerate spec for user routes 2026-09-30 16:43:17 +05:30
vikrantgupta25
fae6fc41e3 feat(authz): enable FGA for users and reset password tokens 2026-09-30 16:43:15 +05:30
praneeth-signoz
81d024dfc8 chore(channel-receivers): relax channel validations (#13008)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Relax all the strict validations like https, host name...for all the
channels. And why this is needed ?

1. Channel URLs were pinned to the vendor's own host —
`chat.googleapis.com`, `*.atlassian.net`— which blocked deployments that
send notifications through a proxy or relay.
2. Provider's contract is not ours to hardcode — so we check the field
is there and let the provider reject what it doesn't accept

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

closes https://github.com/SigNoz/pulse-pod/issues/374
2026-09-30 10:20:41 +00:00
Vikrant Gupta
8dba9a13ea fix(authz): read every body-derived resource id from the decoded request (#13015)
#### Description

- Follows #13014. Moves the remaining body-derived resource ids (gateway
limits, zeus hosts, cloud integration check-ins, auth domains, query
range) off gjson and onto the decoded request, with the handlers reading
the same value. Part of SigNoz/keystone-pod#37.
- Removes `BodyJSONPath`, `BodyJSONArray`, and
`ExtractorContext.RequestBody`.

#### Issues closed by this PR 

- Closes: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-30 09:34:32 +00:00
Vikrant Gupta
8d80f98710 fix(authz): decode the request body once in the resource middleware (#13014)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
#### Description

- The resource middleware now decodes the body once into the route's
declared `OpenAPIDef.Request` type, rejects a malformed body before any
check, and carries the decoded value on `ExtractorContext.Body`.
`BodyField` / `BodyFields` read ids off that value, and handlers read
the same value via `coretypes.BodyFromContext`.
- `handler.Handler` exposes `Request()`, and `handler.New` panics when a
route with resource defs declares a non-pointer request, since the
middleware instantiates it.
- Only `POST /api/v1/service_account_roles` is wired to the new
extractors in this PR to keep the review small. The remaining body
routes still use the gjson extractors and decode again in their
handlers.

#### Issues closed by this PR
- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-29 21:32:25 +00:00
Aditya Singh
adfcebf855 feat(explorer): create alert and add to dashboard actions in the explorer views (#12981)
#### Description
- Create alert and Add to dashboard now live in the view that owns the
row.. logs list row, logs / traces time series and table headers, traces
list and trace rows, metrics per chart. Same line as download
everywhere. First slice of pulling the actions out of the bottom bar,
the bar keeps its own two till it goes so they show twice for now.
- new `ExplorerActions` renders the pair.. the view hands over its
export query, same query the bar gets so nothing changes in what reaches
the alert / dashboard. `TimeSeriesView` got a `headerActions` slot for
it.
- metrics one chart per query: each chart carries its own alert /
dashboard / download for that chart's query, icon only in the split
layout. no per query picker needed.
- alert shaping is source agnostic now.. every noop becomes count and
list / trace panels drop `orderBy` whatever the source. bar only checked
the first query and only stripped for logs. neutral today, traces
already sends `orderBy: []` for those views.
- `DownloadOptionsMenu` moved to the design system buttons.. list
download was the antd primary tinted icon, did not match the rest of the
row.
- llm explorer and meter untouched.. llm gets it later, meter never had
these buttons in the bar.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/56




https://github.com/user-attachments/assets/a3b7f08c-2c62-4243-8cd7-4dd25cb315fd



#### Additional Information
- no flag.. buttons are live from merge, the bar stays till saved views
lands in the sidebar.
- logs controls row is list only now.. everything in it was list gated
once the buttons moved into the view headers, it was an empty strip on
time series / table.
- analytics is one generic event per action with `sourcepage` in the
payload, not the per explorer names the bar used.
- verified bar == button (alert url and dashboard link) in the browser
for all logs / traces views and metrics single, split and unsplit. tests
pin the query pushed to the url per view.
2026-09-29 16:43:05 +00:00
Swapnil Nakade
254758942e fix: using allLogs categoryGroups for azure container apps (#13013)
#### Description

- The Container Apps logs pipeline failed because the definition passed
log category names (`ContainerAppConsoleLogs`, `ContainerAppSystemLogs`)
as `categoryGroups`. Azure accepts only `allLogs` or `audit` there, so
it rejected the diagnostic setting.
- Switched to `allLogs`, matching the other Azure services. The agent
picks this up on its next config sync, so no agent release is needed.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#45

#### Additional Information
- Not tested on live Azure. Microsoft's built-in policy for
`Microsoft.App/managedEnvironments` sends the same `allLogs` setting to
Event Hub.
2026-09-29 14:24:00 +00:00
Aditya Singh
8a5d98f467 chore(logs): remove the old log details drawer (#12938)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `useIsLogDetailsV2` was a route test, not a feature flag.. v2 rendered
on the logs explorer, infra monitoring and dashboards and everything
else fell back to v1. made v2 the only drawer, so the pipelines preview
gets it too. that's the one behaviour change here.
- deleted the v1 code that leaves behind.. the attribute table and its
json processing, the two HOCs, the `ActionItem` component and the
separate JSON tab. `Overview` is down to its DataViewer path, which also
drops monaco from the logs bundle. `DataType` and the two props types
move out first since MetricsExplorer and infra monitoring read them.
- dropped the standalone `/logs/logs-explorer/live` route. nothing
navigated to it, the time picker's Live option is in-page state on the
explorer. the components stay, that in-page mode still renders them.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5933

#### Additional Information

- `onAddToQuery` stays for now. it is v1-only inside the drawer but five
call sites still thread it through, a couple of them doubling it as
`onClickActionItem`.. untangling that is a refactor rather than a
deletion.
- `useLogAttributeActions` gated group-by and replace-filter on "old
explorer or live logs". with both routes gone the guard is always false,
so it collapses.
2026-09-29 13:30:34 +00:00
125 changed files with 6074 additions and 4637 deletions

View File

@@ -184,6 +184,7 @@ components:
headers:
additionalProperties:
type: string
nullable: true
type: object
html:
type: string
@@ -217,6 +218,7 @@ components:
metadata:
additionalProperties:
type: string
nullable: true
type: object
sendResolved:
nullable: true
@@ -258,6 +260,7 @@ components:
type: string
customFields:
additionalProperties: {}
nullable: true
type: object
description:
type: string
@@ -268,6 +271,7 @@ components:
labels:
items:
type: string
nullable: true
type: array
priority:
type: string
@@ -346,6 +350,7 @@ components:
details:
additionalProperties:
type: string
nullable: true
type: object
message:
type: string
@@ -374,6 +379,7 @@ components:
details:
additionalProperties:
type: string
nullable: true
type: object
group:
type: string
@@ -451,6 +457,7 @@ components:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
nullable: true
type: array
apiUrl:
format: password
@@ -464,6 +471,7 @@ components:
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
nullable: true
type: array
footer:
type: string
@@ -1763,12 +1771,15 @@ components:
additionalProperties: {}
nullable: true
type: object
syncState:
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
timestampMillis:
format: int64
type: integer
required:
- timestampMillis
- data
- syncState
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
@@ -2013,6 +2024,8 @@ components:
format: date-time
nullable: true
type: string
syncState:
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
required:
- account_id
- cloud_account_id
@@ -2022,6 +2035,7 @@ components:
- providerAccountId
- integrationConfig
- removedAt
- syncState
type: object
CloudintegrationtypesGettableServicesMetadata:
properties:
@@ -2121,6 +2135,9 @@ components:
type: object
providerAccountId:
type: string
syncedVersion:
nullable: true
type: integer
required:
- data
type: object
@@ -2133,6 +2150,18 @@ 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:
@@ -2274,6 +2303,23 @@ 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:
@@ -21140,9 +21186,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- role:read
- tokenizer:
- ADMIN
- role:read
summary: Get users by role id
tags:
- users
@@ -25728,9 +25774,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:attach
- role:attach
summary: Create user role
tags:
- users
@@ -25781,9 +25829,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:detach
- role:detach
- tokenizer:
- ADMIN
- user:detach
- role:detach
summary: Delete user role
tags:
- users
@@ -25845,9 +25895,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user role
tags:
- users
@@ -25894,9 +25944,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:list
- tokenizer:
- ADMIN
- user:list
summary: List users v2
tags:
- users
@@ -25957,9 +26007,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:create
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:create
- user:attach
- role:attach
summary: Create user
tags:
- users
@@ -26004,9 +26058,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:delete
- tokenizer:
- ADMIN
- user:delete
summary: Delete user
tags:
- users
@@ -26062,9 +26116,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user by user id
tags:
- users
@@ -26119,9 +26173,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:update
- tokenizer:
- ADMIN
- user:update
summary: Update user v2
tags:
- users
@@ -26178,9 +26232,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:list
- tokenizer:
- ADMIN
- factor-password:list
summary: Get reset password token for a user
tags:
- users
@@ -26244,9 +26298,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:create
- user:attach
- tokenizer:
- ADMIN
- factor-password:create
- user:attach
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -26305,9 +26361,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user roles
tags:
- users
@@ -26621,10 +26677,7 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
- tokenizer: []
summary: Updates my password
tags:
- users

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -183,32 +183,52 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
storableAccount, 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
}
// update account with cloud provider account id and agent report (heartbeat)
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
err = module.store.UpdateAccount(ctx, account)
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
// Get account as domain object for config access (enabled regions, etc.)
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
@@ -223,8 +243,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, err
}
// Delegate integration config building entirely to the provider module
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
if err != nil {
return nil, err
}
@@ -234,6 +253,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
account.ID.StringValue(),
integrationConfig,
account.RemovedAt,
syncState,
), nil
}

View File

@@ -33,7 +33,6 @@
"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",

View File

@@ -42,7 +42,6 @@
"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",

View File

@@ -300,14 +300,6 @@ 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' });
@@ -1596,7 +1588,6 @@ 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,

View File

@@ -154,10 +154,6 @@ 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'),
);

View File

@@ -23,7 +23,6 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LiveLogs,
Login,
Logs,
LogsIndexToFields,
@@ -282,13 +281,6 @@ 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,
@@ -521,7 +513,6 @@ 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',

View File

@@ -104,9 +104,9 @@ export interface AlertmanagertypesChannelSlackFieldDTO {
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
* @type array,null
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
actions?: AlertmanagertypesChannelSlackActionDTO[] | null;
/**
* @type string
* @format password
@@ -125,9 +125,9 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
*/
fallback?: string;
/**
* @type array
* @type array,null
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
fields?: AlertmanagertypesChannelSlackFieldDTO[] | null;
/**
* @type string
*/
@@ -166,13 +166,19 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
email = 'email',
}
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
* @type object
* @type object,null
*/
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
/**
@@ -239,10 +245,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
pagerduty = 'pagerduty',
}
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @type string
@@ -265,7 +277,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
*/
description?: string;
/**
* @type object
* @type object,null
*/
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
/**
@@ -307,10 +319,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
opsgenie = 'opsgenie',
}
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @type string
@@ -326,7 +344,7 @@ export interface AlertmanagertypesChannelOpsgenieConfigDTO {
*/
description?: string;
/**
* @type object
* @type object,null
*/
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
/**
@@ -423,10 +441,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @type string
@@ -434,7 +458,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
*/
apiToken: string;
/**
* @type object
* @type object,null
*/
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
/**
@@ -450,9 +474,9 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
*/
issueType: string;
/**
* @type array
* @type array,null
*/
labels?: string[];
labels?: string[] | null;
/**
* @type string
*/
@@ -543,17 +567,23 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
incidentio = 'incidentio',
}
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @type string
*/
description?: string;
/**
* @type object
* @type object,null
*/
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
/**
@@ -3366,6 +3396,37 @@ 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;
};
@@ -3384,6 +3445,7 @@ export type CloudintegrationtypesAgentReportDTO = {
* @type object,null
*/
data: CloudintegrationtypesAgentReportDTOData;
syncState: CloudintegrationtypesSyncStateDTO | null;
/**
* @type integer
* @format int64
@@ -3812,6 +3874,7 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
* @format date-time
*/
removedAt: string | null;
syncState: CloudintegrationtypesSyncStateDTO | null;
}
export interface CloudintegrationtypesServiceMetadataDTO {
@@ -3882,6 +3945,10 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
* @type string
*/
providerAccountId?: string;
/**
* @type integer,null
*/
syncedVersion?: number | null;
}
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {

View File

@@ -106,7 +106,7 @@ describe.each([
renderWithStore(dataSource);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
expect(button).toHaveAccessibleName('Download');
});
it('shows popover with export options when download button is clicked', () => {

View File

@@ -1,11 +1,12 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Tooltip } from 'antd';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
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, LoaderCircle } from '@signozhq/icons';
import { Download } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -111,8 +112,9 @@ export default function DownloadOptionsMenu({
)}
<Button
type="primary"
icon={<Download size={16} />}
variant="solid"
color="primary"
prefix={<Download size={16} />}
onClick={handleExport}
className="export-button"
disabled={isDownloading}
@@ -144,16 +146,14 @@ export default function DownloadOptionsMenu({
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<LoaderCircle size={14} className="animate-spin" />
) : (
<Download size={14} />
)
}
variant="ghost"
color="secondary"
size="icon"
prefix={<Download size={14} />}
aria-label="Download"
data-testid={`periscope-btn-download-${dataSource}`}
disabled={isDownloading}
loading={isDownloading}
/>
</Tooltip>
</Popover>

View File

@@ -1,7 +1,7 @@
import { DrawerProps } from 'antd';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -19,10 +19,6 @@ 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',
@@ -58,7 +54,7 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
);
}
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
describe('LogDetail drawer — header', () => {
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();

View File

@@ -1,16 +1,10 @@
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 cx from 'classnames';
import LogStateIndicator, {
LogType,
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -19,33 +13,24 @@ 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';
@@ -56,7 +41,6 @@ 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';
@@ -64,11 +48,8 @@ import './LogDetails.styles.scss';
function LogDetailInner({
log,
onClose,
onAddToQuery,
onClickActionItem,
selectedTab,
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
@@ -81,7 +62,6 @@ function LogDetailInner({
const [contextQuery, setContextQuery] = useState<Query | undefined>(
initialContextQuery,
);
const [, copyToClipboard] = useCopyToClipboard();
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
@@ -94,8 +74,6 @@ 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 => {
@@ -173,12 +151,6 @@ 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);
@@ -220,13 +192,6 @@ 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
@@ -295,8 +260,6 @@ function LogDetailInner({
}
};
const logType = log?.attributes_string?.log_level || LogType.INFO;
return (
<Drawer
width="60%"
@@ -304,69 +267,15 @@ function LogDetailInner({
maskClosable={false}
getContainer={getContainer}
title={
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>
)
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
}
placement="right"
onClose={drawerCloseHandler}
@@ -385,15 +294,11 @@ function LogDetailInner({
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
{isLogDetailsV2 ? (
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
) : (
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
)}
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
@@ -405,9 +310,9 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<LogHighlights log={log} />
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
@@ -425,21 +330,6 @@ 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: (
@@ -478,26 +368,6 @@ 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] && (
@@ -514,19 +384,11 @@ 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}

View File

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

View File

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

View File

@@ -38,7 +38,6 @@ 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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import styles from './EventsContent.module.scss';

View File

@@ -24,6 +24,7 @@ const accountsResponse: ListAccounts200 = {
agentReport: {
timestampMillis: 1747114366214,
data: null,
syncState: null,
},
providerAccountId: PROVIDER_ACCOUNT_ID,
removedAt: null,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,5 @@
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import { ILog } from 'types/api/logs/log';
export interface BodyTitleRendererProps {
title: string;
nodeKey: string;
value: unknown;
parentIsArray?: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
}
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
export type AnyObject = { [key: string]: any };
@@ -22,6 +13,21 @@ export interface IFieldAttributes {
logType?: MetricsType;
}
export interface JSONViewProps {
logData: ILog;
/** 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;
}

View File

@@ -1,32 +1,13 @@
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 { ReactNode } from 'react';
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';
@@ -38,247 +19,58 @@ 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,
}: 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();
}: OverviewProps): JSX.Element {
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
onApplyLogFilter,
});
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);
};
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
return (
<div className="overview-container">
<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',
<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
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',
},
]}
}}
jsonString={JSON.stringify(raw, null, 2)}
/>
</div>
);
@@ -286,7 +78,6 @@ function Overview({
Overview.defaultProps = {
isListViewPanel: false,
listViewPanelSelectedFields: null,
handleChangeSelectedView: undefined,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
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';
@@ -53,7 +51,6 @@ export function useLogAttributeActions({
isListViewPanel = false,
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
@@ -61,8 +58,6 @@ 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(
@@ -219,8 +214,7 @@ export function useLogAttributeActions({
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported ||
isLiveLogs,
.groupBySupported,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -228,7 +222,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
!handleChangeSelectedView || isRestricted(fieldKeyPath),
},
];
}, [
@@ -236,7 +230,6 @@ export function useLogAttributeActions({
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isLiveLogs,
handleChangeSelectedView,
onApplyLogFilter,
]);

View File

@@ -1,10 +1,7 @@
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,
@@ -13,7 +10,6 @@ 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';
@@ -40,135 +36,6 @@ 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}.` : '';
@@ -254,14 +121,6 @@ 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,
@@ -418,16 +277,6 @@ export const escapeHtml = (unsafe: string): string =>
.replace(/'/g, '&#039;');
// 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, '')
@@ -458,10 +307,6 @@ 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,

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { ReactNode, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
@@ -6,7 +6,6 @@ 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';
@@ -14,18 +13,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,
@@ -60,48 +59,43 @@ function LogsActionsContainer({
<div className="logs-actions-container">
<div className="tab-options">
<div className="tab-options-left">
{selectedPanelType === PANEL_TYPES.LIST && (
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
value={showFrequencyChart}
defaultValue
onChange={handleToggleFrequencyChart}
/>
</div>
)}
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
value={showFrequencyChart}
defaultValue
onChange={handleToggleFrequencyChart}
/>
</div>
</div>
<div className="tab-options-right">
{selectedPanelType === PANEL_TYPES.LIST && (
<>
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
{explorerActions}
<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 && (

View File

@@ -187,6 +187,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -37,6 +37,7 @@ 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';
@@ -140,6 +141,13 @@ function LogsExplorerViewsContainer({
[selectedPanelType, requestData],
);
const explorerActions = useMemo(
() => (
<ExplorerActions query={exportDefaultQuery} sourcepage={DataSource.LOGS} />
),
[exportDefaultQuery],
);
const {
data: listChartData,
isFetching: isFetchingListChartData,
@@ -416,14 +424,14 @@ function LogsExplorerViewsContainer({
return (
<div className="logs-explorer-views-container">
<div className="logs-explorer-views-types">
{!showLiveLogs && (
{!showLiveLogs && selectedPanelType === PANEL_TYPES.LIST && (
<LogsActionsContainer
listQuery={listQuery}
selectedPanelType={selectedPanelType}
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
explorerActions={explorerActions}
/>
)}
@@ -474,21 +482,23 @@ function LogsExplorerViewsContainer({
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
headerActions={explorerActions}
/>
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<div className="table-view-container">
{data && !isError && (
<div className="table-view-container-header">
<div className="table-view-container-header">
{explorerActions}
{data && !isError && (
<ExportMenu
dataSource={DataSource.LOGS}
data={data}
query={stagedQuery || initialQueriesMap.metrics}
fileName="logs-table"
/>
</div>
)}
)}
</div>
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||

View File

@@ -394,6 +394,7 @@ function Explorer(): JSX.Element {
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>

View File

@@ -19,6 +19,7 @@ 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';
@@ -51,6 +52,7 @@ function TimeSeries({
showYAxisUnitSelector,
metrics,
isCancelled = false,
exportDefaultQuery,
}: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -272,6 +274,9 @@ function TimeSeries({
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const exportQuery = changeLayoutForOneChartPerQuery
? queryPayloads[index]
: exportDefaultQuery;
return (
<div
@@ -312,6 +317,14 @@ 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>
);

View File

@@ -4,6 +4,7 @@ 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,
@@ -146,9 +147,11 @@ function renderExplorer(): void {
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
<TooltipProvider>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</TooltipProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,

View File

@@ -1,6 +1,7 @@
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';
@@ -71,6 +72,7 @@ function renderTimeSeries(
yAxisUnit="count"
setYAxisUnit={mockSetYAxisUnit}
showYAxisUnitSelector={false}
exportDefaultQuery={initialQueriesMap.metrics}
{...overrides}
/>,
);

View File

@@ -1,6 +1,7 @@
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;
@@ -17,4 +18,5 @@ export interface TimeSeriesProps {
setYAxisUnit: (unit: string) => void;
showYAxisUnitSelector: boolean;
isCancelled?: boolean;
exportDefaultQuery: Query;
}

View File

@@ -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/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import { CircleArrowDown, CircleArrowRight, Focus } from '@signozhq/icons';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';

View File

@@ -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/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import {
Check,
Copy,

View File

@@ -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/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Save, X } from '@signozhq/icons';

View File

@@ -19,6 +19,7 @@ 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',

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useEffect,
@@ -10,6 +11,7 @@ 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';
@@ -50,7 +52,7 @@ import { getTimeRange } from 'utils/getTimeRange';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import './TimeSeriesView.styles.scss';
import styles from './TimeSeriesView.module.scss';
function TimeSeriesView({
data,
@@ -66,6 +68,7 @@ function TimeSeriesView({
allowExport = false,
exportFileName,
onYAxisUnitChange,
headerActions,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -252,28 +255,31 @@ function TimeSeriesView({
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
const showHeader = showExport || !!onYAxisUnitChange || !!headerActions;
return (
<div className="time-series-view">
<div className={cx('time-series-view', styles.timeSeriesView)}>
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div className={styles.header} data-testid="time-series-view-header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
<div className={styles.headerActions}>
{headerActions}
{showExport && data?.rawV5Response && (
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
</div>
)}
@@ -344,6 +350,8 @@ 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 = {

View File

@@ -131,7 +131,7 @@ describe('TimeSeriesView header gating', () => {
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
const { queryByTestId } = renderView({ withRawV5: false });
expect(queryByTestId('time-series-view-header')).toBeNull();
});
});

View File

@@ -207,4 +207,4 @@ export const routesToSkip = [
ROUTES.AI_OBSERVABILITY_EXPLORER,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
export const routesToDisable = [ROUTES.LOGS_EXPLORER];

View File

@@ -2,6 +2,7 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useCallback,
useEffect,
@@ -55,6 +56,7 @@ interface ListViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
function ListView({
@@ -62,6 +64,7 @@ function ListView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
@@ -227,6 +230,7 @@ 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} />
@@ -272,6 +276,7 @@ function ListView({
ListView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(ListView);

View File

@@ -2,6 +2,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -2,6 +2,7 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -30,10 +31,12 @@ 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();
@@ -101,14 +104,17 @@ function TableView({
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
{!isError && (
<div className="traces-table-view-header">
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
{headerActions}
{data && (
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
)}
</div>
)}
{!isError && (
@@ -125,6 +131,7 @@ function TableView({
TableView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(TableView);

View File

@@ -2,6 +2,7 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -40,6 +41,7 @@ interface TracesViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
function TracesView({
@@ -47,6 +49,7 @@ function TracesView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
@@ -155,6 +158,7 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
{headerActions}
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
@@ -187,6 +191,7 @@ function TracesView({
TracesView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(TracesView);

View File

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

View File

@@ -1,9 +0,0 @@
export const fieldSearchFilter = (
searchSpace = '',
currentValue = '',
): boolean => {
if (!currentValue || !searchSpace) {
return true;
}
return searchSpace.toLowerCase().indexOf(currentValue.toLowerCase()) !== -1;
};

View File

@@ -295,7 +295,11 @@ const account = (
provider,
providerAccountId: ACCOUNTS[provider][index],
config: accountConfig(provider),
agentReport: { timestampMillis: Date.now() - 45 * 1000, data: null },
agentReport: {
timestampMillis: Date.now() - 45 * 1000,
data: null,
syncState: null,
},
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
removedAt: null,

View File

@@ -1,6 +1,7 @@
import {
Dispatch,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -29,6 +30,7 @@ function TimeSeriesViewContainer({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
@@ -126,6 +128,7 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
headerActions={headerActions}
/>
</div>
);
@@ -137,11 +140,13 @@ 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;

View File

@@ -13,6 +13,8 @@ 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';
@@ -194,6 +196,27 @@ 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) {
@@ -318,6 +341,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}
@@ -329,6 +353,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}
@@ -341,6 +366,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}
@@ -351,6 +377,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}

View File

@@ -95,7 +95,6 @@ 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'],
@@ -169,7 +168,6 @@ export const routeWithInitialAuthZSupport = {
TRACE_DETAIL: true,
LOGS: true,
LOGS_EXPLORER: true,
LIVE_LOGS: true,
ROLES_SETTINGS: true,
ROLE_CREATE: true,
ROLE_DETAILS: true,

View File

@@ -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.ToStoredType())
q = q.Where("type = ?", params.Kind.StringValue())
}
q = q.

View File

@@ -98,11 +98,12 @@ func (handler *handler) ListChannels(rw http.ResponseWriter, req *http.Request)
}
// This ensures that the UI receives an empty array instead of null
if len(channels) == 0 {
channels = make([]*alertmanagertypes.Channel, 0)
v1Channels := make([]*alertmanagertypes.Channel, 0, len(channels))
for _, channel := range channels {
v1Channels = append(v1Channels, channel.ToV1Channel())
}
render.Success(rw, http.StatusOK, channels)
render.Success(rw, http.StatusOK, v1Channels)
}
func (handler *handler) ListAllChannels(rw http.ResponseWriter, req *http.Request) {
@@ -152,7 +153,7 @@ func (handler *handler) GetChannelByID(rw http.ResponseWriter, req *http.Request
return
}
render.Success(rw, http.StatusOK, channel)
render.Success(rw, http.StatusOK, channel.ToV1Channel())
}
func (handler *handler) UpdateChannelByID(rw http.ResponseWriter, req *http.Request) {
@@ -272,7 +273,7 @@ func (handler *handler) CreateChannel(rw http.ResponseWriter, req *http.Request)
return
}
render.Success(rw, http.StatusCreated, channel)
render.Success(rw, http.StatusCreated, channel.ToV1Channel())
}
func (handler *handler) CreateRoutePolicy(rw http.ResponseWriter, req *http.Request) {

View File

@@ -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) {
receiver, err := postable.ToReceiver()
channel, receiver, err := postable.ToChannel(orgID)
if err != nil {
return nil, err
}
@@ -280,11 +280,6 @@ 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)
}))
@@ -304,15 +299,11 @@ func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID s
return nil, err
}
receiver, err := updatable.ToReceiver(channel.DisplayName)
receiver, err := channel.UpdateFromUpdatable(updatable)
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

View File

@@ -85,6 +85,7 @@ type provider struct {
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
userGetter user.Getter
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
@@ -129,6 +130,7 @@ func NewFactory(
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
userGetter user.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -181,6 +183,7 @@ func NewFactory(
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
userGetter,
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
@@ -235,6 +238,7 @@ func newProvider(
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
userGetter user.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -289,6 +293,7 @@ func newProvider(
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
userGetter: userGetter,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,

View File

@@ -6,24 +6,35 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addUserRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.ListUsers, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -61,20 +72,46 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceUser.Scope(coretypes.VerbCreate),
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: provider.roleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -95,88 +132,152 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.UpdateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate),
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceUser,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -209,7 +310,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -248,90 +349,190 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetRolesByUserID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUsersByRoleID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.userRoleUserIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(provider.userRoleUserIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.userRoleRoleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
userRole, err := provider.userRoleFromRequest(ec.Request)
if err != nil {
return "", err
}
return userRole.UserID.String(), nil
})
}
func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
userRole, err := provider.userRoleFromRequest(ec.Request)
if err != nil {
return "", err
}
return userRole.RoleID.String(), nil
})
}
func (provider *provider) userRoleFromRequest(req *http.Request) (*authtypes.UserRole, error) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
return nil, err
}
userRoleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
return provider.userGetter.GetUserRoleByOrgIDAndID(req.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
}

View File

@@ -56,20 +56,23 @@ type AttachDetachSiblingResourceDef struct {
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
),
resolved := coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
)
if resolved.HasNoLinks() {
return nil
}
return []coretypes.ResolvedResource{resolved}
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child

View File

@@ -0,0 +1,71 @@
package handler
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAttachDetachSiblingResourceDefResolvesNothingWithoutLinks(t *testing.T) {
def := AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: coretypes.IDSelector,
}
testCases := []struct {
name string
body string
expectedResolved int
expectedTargetIDs []string
}{
{name: "NoRolesKey_ResolvesNothing", body: `{"email":"jane@example.com"}`, expectedResolved: 0},
{name: "EmptyRoles_ResolvesNothing", body: `{"userRoles":[]}`, expectedResolved: 0},
{name: "OneRole_ResolvesOne", body: `{"userRoles":[{"id":"signoz-viewer"}]}`, expectedResolved: 1, expectedTargetIDs: []string{"signoz-viewer"}},
{name: "TwoRoles_ResolvesOneWithBothTargets", body: `{"userRoles":[{"id":"signoz-viewer"},{"id":"signoz-editor"}]}`, expectedResolved: 1, expectedTargetIDs: []string{"signoz-viewer", "signoz-editor"}},
{name: "EmptyRoleID_KeepsFailingClosed", body: `{"userRoles":[{"id":""}]}`, expectedResolved: 1, expectedTargetIDs: []string{""}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.Len(t, resolved, testCase.expectedResolved)
if testCase.expectedResolved == 0 {
return
}
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.NoError(t, withTarget.Err())
assert.Equal(t, []string{""}, withTarget.SourceIDs())
assert.Equal(t, testCase.expectedTargetIDs, withTarget.TargetIDs())
})
}
}
func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
def := AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: coretypes.IDSelector,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{RequestBody: []byte(`{"userId":"u1"}`)})
require.Len(t, resolved, 1)
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}

View File

@@ -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.14",
Version: "v0.0.15",
},
}
}

View File

@@ -246,7 +246,7 @@
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
"categoryGroups": ["allLogs"]
}
}
},

View File

@@ -134,6 +134,24 @@ 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).

View File

@@ -184,11 +184,6 @@ func (handler *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
if userID == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
updatableUser := new(types.UpdatableUser)
if err := json.NewDecoder(r.Body).Decode(&updatableUser); err != nil {
render.Error(w, err)
@@ -431,11 +426,6 @@ func (handler *handler) CreateUserRole(w http.ResponseWriter, r *http.Request) {
return
}
if req.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
userRole, err := handler.setter.AddUserRoleByRoleID(ctx, valuer.MustNewUUID(claims.OrgID), req.UserID, req.RoleID)
if err != nil {
render.Error(w, err)
@@ -492,11 +482,6 @@ func (handler *handler) DeleteUserRole(w http.ResponseWriter, r *http.Request) {
return
}
if userRole.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
if err := handler.setter.RemoveUserRole(ctx, valuer.MustNewUUID(claims.OrgID), userRole.UserID, userRole.RoleID); err != nil {
render.Error(w, err)
return

View File

@@ -94,6 +94,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},
struct{ serviceaccount.Getter }{},
struct{ user.Getter }{},
struct{ factory.Handler }{},
struct{ cloudintegration.Handler }{},
struct{ rulestatehistory.Handler }{},

View File

@@ -257,6 +257,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
sqlmigration.NewAddChannelSpecFactory(sqlschema),
sqlmigration.NewAddUserTuplesFactory(sqlstore),
)
}
@@ -352,6 +354,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
modules.ServiceAccountGetter,
modules.UserGetter,
handlers.RegistryHandler,
handlers.CloudIntegrationHandler,
handlers.RuleStateHistory,

View File

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

View File

@@ -0,0 +1,141 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addUserTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddUserTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addUserTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addUserTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// user and factor-password moved from the legacy AdminAccess gate to
// CheckResources. Existing organizations never had these tuples written;
// only new organizations receive them from the managed-role registry at bootstrap.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "user", "user", "create"},
{authtypes.SigNozAdminRoleName, "user", "user", "list"},
{authtypes.SigNozAdminRoleName, "user", "user", "read"},
{authtypes.SigNozAdminRoleName, "user", "user", "update"},
{authtypes.SigNozAdminRoleName, "user", "user", "delete"},
{authtypes.SigNozAdminRoleName, "user", "user", "attach"},
{authtypes.SigNozAdminRoleName, "user", "user", "detach"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addUserTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,18 +1,14 @@
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"
)
@@ -30,20 +26,6 @@ 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"`
@@ -55,45 +37,51 @@ 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"`
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"`
// 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"`
}
// 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)
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
}
// 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,
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)
}
data, err := json.Marshal(receiver)
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)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "marshal receiver")
return errors.WrapInternalf(err, errors.CodeInternal, "marshal channel %q spec", c.DisplayName)
}
channel.Data = string(data)
c.Spec, c.StoredSpec = spec, string(stored)
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
return nil
}
const channelNameSuffixLen = 8
@@ -137,57 +125,6 @@ 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,
@@ -228,64 +165,3 @@ 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
}

View File

@@ -16,8 +16,20 @@ import (
type ChannelEmailConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
To string `json:"to" required:"true"`
HTML valuer.UnsetOrNonEmptyString `json:"html"`
Headers map[string]string `json:"headers,omitempty"`
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()
}
func (c ChannelEmailConfig) Validate() error {

View File

@@ -1,9 +1,6 @@
package alertmanagertypes
import (
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
@@ -13,8 +10,21 @@ import (
type ChannelGoogleChatConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
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()
}
func (c ChannelGoogleChatConfig) Validate() error {
@@ -89,15 +99,5 @@ 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
}

View File

@@ -13,8 +13,6 @@ 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 {

View File

@@ -1,8 +1,6 @@
package alertmanagertypes
import (
"fmt"
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -15,9 +13,22 @@ 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"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Metadata map[string]string `json:"metadata,omitempty"`
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()
}
func (c ChannelIncidentIOConfig) Validate() error {
@@ -64,11 +75,6 @@ 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 (
@@ -132,19 +138,8 @@ func (c *IncidentIOReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
c.Description = DefaultIncidentIODescriptionTemplate
}
// 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")
if c.URL == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url is required")
}
token := string(c.Token)

View File

@@ -45,11 +45,6 @@ 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)},

View File

@@ -2,7 +2,6 @@ package alertmanagertypes
import (
"fmt"
"net/url"
"strings"
"time"
@@ -20,20 +19,35 @@ 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"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Priority string `json:"priority"`
Labels []string `json:"labels,omitempty"`
Labels []string `json:"labels,omitzero"`
ResolveTransition string `json:"resolveTransition"`
ReopenTransition string `json:"reopenTransition"`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero"`
WontFixResolution string `json:"wontFixResolution"`
CustomFields map[string]any `json:"customFields,omitempty"`
CustomFields map[string]any `json:"customFields,omitzero"`
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
@@ -153,7 +167,6 @@ 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/"
)
@@ -204,11 +217,13 @@ 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
}
@@ -216,19 +231,9 @@ func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
c.Description = DefaultJiraDescriptionTemplate
}
// 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")
if c.Site == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site is required")
}
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")
}

View File

@@ -11,19 +11,19 @@ import (
"github.com/stretchr/testify/require"
)
func jiraReceiverJSON(site, project, issueType string, withAuth bool) string {
func jiraReceiverJSON(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":%q,"project":%q,"issue_type":%q%s}]}`,
site, project, issueType, auth,
`{"name":"jira","jira_configs":[{"site":"https://acme.atlassian.net","project":%q,"issue_type":%q%s}]}`,
project, issueType, auth,
)
}
func TestJiraReceiverConfigDefaults(t *testing.T) {
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true))
r, err := NewReceiver(jiraReceiverJSON("KAN", "Task", true))
require.NoError(t, err)
require.Len(t, r.JiraConfigs, 1)
@@ -93,17 +93,10 @@ 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"}}}]}`},
{"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)},
{"missing project", jiraReceiverJSON("", "Task", true)},
{"missing issue_type", jiraReceiverJSON("KAN", "", true)},
{"missing basic auth", jiraReceiverJSON("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) {

View File

@@ -12,11 +12,25 @@ import (
type ChannelJSMOpsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
Message valuer.UnsetOrNonEmptyString `json:"message"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Priority string `json:"priority"`
// Tags is the comma-separated list JSM Ops attaches to the alert.
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
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()
}
func (c ChannelJSMOpsConfig) Validate() error {

View File

@@ -9,8 +9,21 @@ import (
type ChannelMSTeamsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
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()
}
func (c ChannelMSTeamsConfig) Validate() error {

View File

@@ -10,13 +10,27 @@ 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"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Source valuer.UnsetOrNonEmptyString `json:"source"`
Details map[string]string `json:"details,omitempty"`
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"`
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")

View File

@@ -10,15 +10,39 @@ 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"`
Client valuer.UnsetOrNonEmptyString `json:"client"`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero"`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Severity string `json:"severity"`
Component string `json:"component"`
Group string `json:"group"`
Class string `json:"class"`
Details map[string]string `json:"details,omitempty"`
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()
}
func (c ChannelPagerdutyConfig) Validate() error {
@@ -90,3 +114,32 @@ 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
}

View File

@@ -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"`
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"`
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"`
}
type ChannelSlackField struct {
@@ -46,6 +46,24 @@ 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")

View File

@@ -1,11 +1,16 @@
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,
@@ -18,6 +23,17 @@ 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")
@@ -96,3 +112,16 @@ 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
}

View File

@@ -1,104 +1,50 @@
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 {
newSpec, ok := newChannelSpec(c.Kind)
expected, ok := buildEmptyChannelSpecForKind(c.Kind)
if !ok {
return ErrUnsupportedChannelKind(c.Kind.StringValue())
}
@@ -107,44 +53,21 @@ func (c ChannelConfig) Validate() error {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec is required")
}
spec, ok := c.Spec.(ChannelSpec)
if !ok {
return errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
spec, err := c.toChannelSpec()
if err != nil {
return err
}
// 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()) {
// 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) {
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 {
@@ -193,311 +116,6 @@ 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"
@@ -514,6 +132,18 @@ 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.

View File

@@ -3,8 +3,11 @@ 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"
)
@@ -12,13 +15,47 @@ 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, ok := p.Config.Spec.(ChannelSpec)
if !ok {
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
spec, err := p.Config.toChannelSpec()
if err != nil {
return nil, err
}
receiver, err := spec.toUndefaultedReceiver(p.DisplayName)
@@ -45,22 +82,63 @@ 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
// ════════════════════════════════════════════════════════════════════════
// 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) {
// 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) {
receiver := &Receiver{Receiver: &config.Receiver{}}
if err := json.Unmarshal([]byte(c.Data), receiver); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
return ChannelConfig{}, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
}
if total := countNotifierConfigs(receiver); total > 1 {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
}
for _, channelKind := range channelKinds {
@@ -70,17 +148,21 @@ func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel,
spec, err := channelKind.extractSpec(c.DisplayName, receiver)
if err != nil {
return nil, err
return ChannelConfig{}, err
}
return &PostableNotificationChannel{
Name: c.Name,
DisplayName: c.DisplayName,
Config: ChannelConfig{Kind: channelKind.kind, Spec: spec},
}, nil
// 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 nil, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
}
// countNotifierConfigs totals every *_configs entry on the receiver, including
@@ -111,15 +193,15 @@ func countConfigsFields(v reflect.Value) int {
}
func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel, error) {
postable, err := c.toPostableNotificationChannel()
channelConfig, err := c.toChannelConfig()
if err != nil {
return nil, err
}
return &GettableNotificationChannel{
Name: postable.Name,
DisplayName: postable.DisplayName,
Config: postable.Config,
Name: c.Name,
DisplayName: c.DisplayName,
Config: channelConfig,
ID: c.ID,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
@@ -130,7 +212,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, _ := parseStoredChannelType(c.Type)
channelKind, _ := parseChannelKind(c.Type)
return &ListedNotificationChannel{
ID: c.ID,

View File

@@ -4,7 +4,6 @@ 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"
@@ -19,7 +18,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 TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
sendResolved := true
short := true
@@ -270,18 +269,14 @@ func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
}
require.NoError(t, postable.Validate())
receiver, err := postable.ToReceiver()
channel, _, err := postable.ToChannel("org-1")
require.NoError(t, err)
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
derived, err := channel.deriveChannelConfig()
require.NoError(t, err)
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)
assert.Equal(t, testCase.kind, derived.Kind)
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
})
}
}
@@ -299,10 +294,7 @@ func TestPostableChannelToReceiverOmitsEmailTransportCredentials(t *testing.T) {
},
}
receiver, err := postable.ToReceiver()
require.NoError(t, err)
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
channel, _, err := postable.ToChannel("org-1")
require.NoError(t, err)
for _, credentialKey := range []string{"auth_username", "auth_password", "auth_secret", "tls_config"} {
@@ -354,40 +346,17 @@ func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
}
require.NoError(t, postable.Validate())
receiver, err := postable.ToReceiver()
require.NoError(t, err)
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
channel, _, err := postable.ToChannel("org-1")
require.NoError(t, err)
assert.Contains(t, channel.Data, testCase.expectedInData)
roundTripped, err := channel.toPostableNotificationChannel()
derived, err := channel.deriveChannelConfig()
require.NoError(t, err)
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
assert.Equal(t, testCase.expectedRoundTrip, derived.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.
@@ -396,7 +365,7 @@ func TestRejectUnrepresentableHTTPConfigCoversEveryUpstreamMember(t *testing.T)
assert.Equal(t, 5, reflect.TypeFor[commoncfg.ProxyConfig]().NumField())
}
func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
testCases := []struct {
description string
channel Channel
@@ -558,7 +527,7 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
_, err := testCase.channel.toPostableNotificationChannel()
_, err := testCase.channel.deriveChannelConfig()
assert.Error(t, err)
})
}
@@ -566,7 +535,7 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(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 TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
func TestDeriveChannelConfigReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
sendResolved := config.DefaultWebhookConfig.VSendResolved
testCases := []struct {
@@ -595,10 +564,10 @@ func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *te
t.Run(testCase.name, func(t *testing.T) {
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
postable, err := channel.toPostableNotificationChannel()
derived, err := channel.deriveChannelConfig()
require.NoError(t, err)
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
assert.Equal(t, ChannelKindWebhook, derived.Kind)
assert.Equal(t, testCase.expectedWebhookSpec, derived.Spec)
})
}
}

Some files were not shown because too many files have changed in this diff Show More