mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-30 15:20:45 +01:00
Compare commits
34 Commits
feat/scatt
...
keystone-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3aef7b48 | ||
|
|
e374d03e54 | ||
|
|
d08cd99107 | ||
|
|
507ea382cf | ||
|
|
58acd6d337 | ||
|
|
e4afc8832b | ||
|
|
585a3b6140 | ||
|
|
6a1fac8118 | ||
|
|
b8869e7005 | ||
|
|
2a5647f6b8 | ||
|
|
ed45d5054d | ||
|
|
057b61fe68 | ||
|
|
d445b6c296 | ||
|
|
643de2055d | ||
|
|
86d9dae524 | ||
|
|
fd8aaac300 | ||
|
|
7f3ec73dfd | ||
|
|
e4cd8dbf10 | ||
|
|
a39eec0f5c | ||
|
|
fae6fc41e3 | ||
|
|
81d024dfc8 | ||
|
|
8dba9a13ea | ||
|
|
8d80f98710 | ||
|
|
adfcebf855 | ||
|
|
254758942e | ||
|
|
8a5d98f467 | ||
|
|
47dd1fabf3 | ||
|
|
270988fb48 | ||
|
|
39badeb591 | ||
|
|
ec05bfe755 | ||
|
|
ed1bf7ab89 | ||
|
|
6e979c8318 | ||
|
|
3b6becff7a | ||
|
|
ab715533b9 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -38,7 +38,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
@@ -64,6 +63,7 @@ jobs:
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- ruler
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"> </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}
|
||||
|
||||
@@ -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}/`)
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -47,4 +47,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,15 +3,22 @@ import {
|
||||
MessageActionKindDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import type { History } from 'history';
|
||||
|
||||
import {
|
||||
@@ -31,8 +38,7 @@ import {
|
||||
} from '../resolveOpenResource';
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/saveView/getAllViews');
|
||||
jest.mock('api/saveView/getViewById');
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
@@ -48,43 +54,45 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockedGetAllViews = getAllViews as jest.MockedFunction<
|
||||
typeof getAllViews
|
||||
const mockedListSavedViews = listSavedViews as jest.MockedFunction<
|
||||
typeof listSavedViews
|
||||
>;
|
||||
const mockedGetViewById = getViewById as jest.MockedFunction<
|
||||
typeof getViewById
|
||||
const mockedGetSavedView = getSavedView as jest.MockedFunction<
|
||||
typeof getSavedView
|
||||
>;
|
||||
|
||||
function makeView(id: string, sourcePage: DataSource): ViewProps {
|
||||
function makeView(
|
||||
id: string,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id,
|
||||
name: `View ${id}`,
|
||||
category: 'test',
|
||||
name: `view-${id}`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdAt: '2021-07-07T06:31:00.000Z',
|
||||
createdBy: 'user',
|
||||
updatedAt: '2021-07-07T06:33:00.000Z',
|
||||
updatedBy: 'user',
|
||||
sourcePage,
|
||||
tags: [],
|
||||
extraData: '',
|
||||
compositeQuery: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
} as ICompositeMetricQuery,
|
||||
};
|
||||
spec: {
|
||||
displayName: `View ${id}`,
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [{ type: 'builder_query', spec: { name: 'A', signal: source } }],
|
||||
},
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
|
||||
return {
|
||||
data: { status: 'success', data: views },
|
||||
} as AxiosResponse<AllViewsProps>;
|
||||
function mockViewsResponse(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
): ListSavedViews200 {
|
||||
return { status: 'success', data: views };
|
||||
}
|
||||
|
||||
function mockViewByIdResponse(
|
||||
view: ViewProps,
|
||||
): AxiosResponse<{ status: string; data: ViewProps }> {
|
||||
return {
|
||||
data: { status: 'success', data: view },
|
||||
} as AxiosResponse<{ status: string; data: ViewProps }>;
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 {
|
||||
return { status: 'success', data: view };
|
||||
}
|
||||
|
||||
describe('resourceRoute', () => {
|
||||
@@ -190,18 +198,33 @@ describe('resolveOpenResource', () => {
|
||||
|
||||
describe('findSavedViewInLists', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedListSavedViews.mockReset();
|
||||
});
|
||||
|
||||
it('loads only the hinted source when entity is provided', async () => {
|
||||
const tracesView = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
const tracesView = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(result).toStrictEqual(tracesView);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a null list as empty and probes the next source', async () => {
|
||||
const metricsView = makeView('view-metrics', SavedviewtypesSourceDTO.metrics);
|
||||
mockedListSavedViews
|
||||
.mockResolvedValueOnce({ status: 'success', data: null })
|
||||
.mockResolvedValueOnce(mockViewsResponse([]))
|
||||
.mockResolvedValueOnce(mockViewsResponse([metricsView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-metrics');
|
||||
|
||||
expect(result).toStrictEqual(metricsView);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,52 +250,75 @@ describe('openSavedView', () => {
|
||||
it('navigates with history.push and view query params', () => {
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
|
||||
openSavedView(view, history);
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = push.mock.calls[0][0] as string;
|
||||
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(pushedUrl).toContain(QueryParams.viewKey);
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1]);
|
||||
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
|
||||
expect(params.get(QueryParams.viewName)).toBe('"View view-logs"');
|
||||
expect(params.get(QueryParams.panelTypes)).toBe('"list"');
|
||||
});
|
||||
|
||||
it('throws when the view has no source', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
delete view.source;
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Unsupported saved view source');
|
||||
});
|
||||
|
||||
it('throws when the view has no queries', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
view.spec.queries = [];
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Saved view is missing query data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedGetViewById.mockReset();
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetSavedView.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
mockedGetSavedView.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
|
||||
|
||||
expect(mockedGetViewById).toHaveBeenCalledWith('view-logs');
|
||||
expect(mockedGetAllViews).not.toHaveBeenCalled();
|
||||
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
|
||||
expect(mockedListSavedViews).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to list probing when direct lookup fails', async () => {
|
||||
const view = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const view = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
|
||||
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { History } from 'history';
|
||||
|
||||
type SavedViewSourceHint = DataSource | 'meter';
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
DataSource.LOGS,
|
||||
@@ -20,13 +27,15 @@ const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
export async function findSavedViewInLists(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps | null> {
|
||||
): Promise<SavedviewtypesSavedViewDTO | null> {
|
||||
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await getAllViews(source);
|
||||
const match = response.data.data.find((view) => view.id === viewKey);
|
||||
const response = await listSavedViews({
|
||||
source: toSavedViewSource(source),
|
||||
});
|
||||
const match = findSavedView(response.data, viewKey);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
@@ -41,11 +50,11 @@ export async function findSavedViewInLists(
|
||||
async function loadSavedView(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps> {
|
||||
): Promise<SavedviewtypesSavedViewDTO> {
|
||||
try {
|
||||
const response = await getViewById(viewKey);
|
||||
if (response.data?.data) {
|
||||
return response.data.data;
|
||||
const response = await getSavedView({ id: viewKey });
|
||||
if (response.data) {
|
||||
return response.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to list probing when the direct lookup fails.
|
||||
@@ -85,20 +94,23 @@ export function buildExplorerNavigationUrl(
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: ViewProps, history: History): void {
|
||||
const route = explorerRouteForSourcePage(view.sourcePage);
|
||||
export function openSavedView(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
history: History,
|
||||
): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
}
|
||||
|
||||
if (!view.compositeQuery) {
|
||||
if (!view.spec.queries?.length) {
|
||||
throw new Error('Saved view is missing query data');
|
||||
}
|
||||
|
||||
const query = mapQueryDataFromApi(view.compositeQuery);
|
||||
const query = getSavedViewQuery(view);
|
||||
const url = buildExplorerNavigationUrl(route, query, {
|
||||
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.name,
|
||||
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.spec.displayName,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
@@ -112,6 +124,3 @@ export async function openSavedViewByKey(
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
}
|
||||
|
||||
/** @deprecated Use findSavedViewInLists — kept for tests. */
|
||||
export const findSavedView = findSavedViewInLists;
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
z-index: 0;
|
||||
background: var(--l1-background);
|
||||
|
||||
// Column so the bottom strip sits under the scrolling content, not inside it.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.full-screen-content {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -70,7 +74,9 @@
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: calc(20px + var(--bottom-strip-height, 0px));
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
|
||||
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import BottomStrip from 'container/BottomStrip';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -51,6 +52,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
@@ -402,6 +404,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
@@ -868,6 +871,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
|
||||
<BottomStrip />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && isAIAssistantEnabled && (
|
||||
|
||||
@@ -12,8 +12,12 @@ export const Layout = styled(LayoutComponent)`
|
||||
}
|
||||
`;
|
||||
|
||||
// Takes the height left in `.app-content` after the bottom strip.
|
||||
// `min-height: 0` is not needed right now, overlayscrollbars already sets
|
||||
// `overflow: auto` here. Kept so this does not break if that goes away.
|
||||
export const LayoutContent = styled(LayoutComponent.Content)`
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
36
frontend/src/container/BottomStrip/BottomStrip.module.scss
Normal file
36
frontend/src/container/BottomStrip/BottomStrip.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
flex-shrink: 0;
|
||||
height: var(--bottom-strip-height);
|
||||
padding: 0 var(--spacing-6);
|
||||
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
// cover the strip.
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
// The string is whatever the Go build injected, so it is rendered untouched —
|
||||
// same as SideNav. Release tags carry the "v", local builds do not.
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
frontend/src/container/BottomStrip/index.tsx
Normal file
42
frontend/src/container/BottomStrip/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
export const BOTTOM_STRIP_HEIGHT = 24;
|
||||
|
||||
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomStrip;
|
||||
@@ -1,6 +1,8 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
109
frontend/src/container/ExplorerActions/AddToDashboardButton.tsx
Normal file
109
frontend/src/container/ExplorerActions/AddToDashboardButton.tsx
Normal 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;
|
||||
61
frontend/src/container/ExplorerActions/CreateAlertButton.tsx
Normal file
61
frontend/src/container/ExplorerActions/CreateAlertButton.tsx
Normal 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;
|
||||
39
frontend/src/container/ExplorerActions/ExplorerActions.tsx
Normal file
39
frontend/src/container/ExplorerActions/ExplorerActions.tsx
Normal 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;
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
144
frontend/src/container/ExplorerActions/__tests__/utils.test.ts
Normal file
144
frontend/src/container/ExplorerActions/__tests__/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
frontend/src/container/ExplorerActions/utils.ts
Normal file
85
frontend/src/container/ExplorerActions/utils.ts
Normal 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),
|
||||
)}`;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.explorer-option-droppable-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
width: -webkit-fill-available;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import floppyDiscUrl from '@/assets/Icons/floppy-disc.svg';
|
||||
@@ -35,38 +36,40 @@ export default function SavedViews({
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<any[]>([]);
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<
|
||||
SavedviewtypesSavedViewDTO[]
|
||||
>([]);
|
||||
|
||||
const {
|
||||
data: logsViewsData,
|
||||
isLoading: logsViewsLoading,
|
||||
isError: logsViewsError,
|
||||
} = useGetAllViews(DataSource.LOGS);
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.logs });
|
||||
|
||||
const {
|
||||
data: tracesViewsData,
|
||||
isLoading: tracesViewsLoading,
|
||||
isError: tracesViewsError,
|
||||
} = useGetAllViews(DataSource.TRACES);
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.traces });
|
||||
|
||||
const {
|
||||
data: metricsViewsData,
|
||||
isLoading: metricsViewsLoading,
|
||||
isError: metricsViewsError,
|
||||
} = useGetAllViews(DataSource.METRICS);
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.metrics });
|
||||
|
||||
const logsViews = useMemo(
|
||||
() => [...(logsViewsData?.data.data || [])],
|
||||
() => [...(logsViewsData?.data || [])],
|
||||
[logsViewsData],
|
||||
);
|
||||
|
||||
const tracesViews = useMemo(
|
||||
() => [...(tracesViewsData?.data.data || [])],
|
||||
() => [...(tracesViewsData?.data || [])],
|
||||
[tracesViewsData],
|
||||
);
|
||||
|
||||
const metricsViews = useMemo(
|
||||
() => [...(metricsViewsData?.data.data || [])],
|
||||
() => [...(metricsViewsData?.data || [])],
|
||||
[metricsViewsData],
|
||||
);
|
||||
|
||||
@@ -88,39 +91,22 @@ export default function SavedViews({
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const handleRedirectQuery = (view: ViewProps): void => {
|
||||
const handleRedirectQuery = (view: SavedviewtypesSavedViewDTO): void => {
|
||||
logEvent('Homepage: Saved view clicked', {
|
||||
viewId: view.id,
|
||||
viewName: view.name,
|
||||
viewName: view.spec.displayName,
|
||||
entity: selectedEntity,
|
||||
});
|
||||
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (selectedEntity === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (selectedEntity === 'traces') {
|
||||
currentViews = tracesViews;
|
||||
} else if (selectedEntity === 'metrics') {
|
||||
currentViews = metricsViews;
|
||||
}
|
||||
|
||||
const currentViewDetails = getViewDetailsUsingViewKey(view.id, currentViews);
|
||||
if (!currentViewDetails) {
|
||||
return;
|
||||
}
|
||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||
|
||||
if (selectedEntity) {
|
||||
handleExplorerTabChange(
|
||||
currentPanelType,
|
||||
{
|
||||
query,
|
||||
viewName: name,
|
||||
viewKey: id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
}
|
||||
handleExplorerTabChange(
|
||||
view.spec.panelType,
|
||||
{
|
||||
query: getSavedViewQuery(view),
|
||||
viewName: view.spec.displayName,
|
||||
viewKey: view.id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,24 +225,10 @@ export default function SavedViews({
|
||||
/>
|
||||
|
||||
<div className="saved-view-item-name home-data-item-name">
|
||||
{view.name}
|
||||
{view.spec.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="saved-view-item-description home-data-item-tag">
|
||||
{view.tags?.map((tag: string) => {
|
||||
if (tag === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="sienna" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -307,7 +279,7 @@ export default function SavedViews({
|
||||
logEvent('Homepage: Saved views switched', {
|
||||
tab,
|
||||
});
|
||||
let currentViews: ViewProps[] = [];
|
||||
let currentViews: SavedviewtypesSavedViewDTO[] = [];
|
||||
if (tab === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (tab === 'traces') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const accountsResponse: ListAccounts200 = {
|
||||
agentReport: {
|
||||
timestampMillis: 1747114366214,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
providerAccountId: PROVIDER_ACCOUNT_ID,
|
||||
removedAt: null,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.licenses-page {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.licenses-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
@@ -32,7 +29,6 @@
|
||||
|
||||
.licenses-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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, ''');
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -394,6 +394,7 @@ function Explorer(): JSX.Element {
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -181,7 +181,9 @@
|
||||
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new
|
||||
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
width: calc(100% - 54px);
|
||||
background: var(--l1-background);
|
||||
padding: 16px;
|
||||
|
||||
@@ -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',
|
||||
|
||||
126
frontend/src/container/SavedViews/__tests__/utils.test.ts
Normal file
126
frontend/src/container/SavedViews/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function makeView(): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
name: 'errors-by-service-abc123',
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'a@b.c',
|
||||
updatedBy: 'a@b.c',
|
||||
spec: {
|
||||
displayName: 'Errors by service',
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
stepInterval: 60,
|
||||
filter: { expression: 'has_error = true' },
|
||||
// v2 reads back fully defaulted envelopes; nulls must not break the mapper
|
||||
groupBy: null,
|
||||
order: null,
|
||||
selectFields: null,
|
||||
functions: null,
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: [{ name: 'service.name' }],
|
||||
display: { color: 'red' },
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
describe('getSavedViewQuery', () => {
|
||||
it('maps the v2 spec through the v5 branch of mapQueryDataFromApi', () => {
|
||||
const query = getSavedViewQuery(makeView());
|
||||
|
||||
expect(query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query.promql).toStrictEqual([]);
|
||||
expect(query.clickhouse_sql).toStrictEqual([]);
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
|
||||
const [queryData] = query.builder.queryData;
|
||||
expect(queryData.queryName).toBe('A');
|
||||
expect(queryData.dataSource).toBe(DataSource.TRACES);
|
||||
expect(queryData.filter).toStrictEqual({ expression: 'has_error = true' });
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps formulas alongside builder queries', () => {
|
||||
const view = makeView();
|
||||
view.spec.queries.push({
|
||||
type: 'builder_formula',
|
||||
spec: { name: 'F1', expression: 'A / 2' },
|
||||
} as SavedviewtypesSavedViewDTO['spec']['queries'][number]);
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
|
||||
});
|
||||
|
||||
it('does not read the panel type into the query', () => {
|
||||
const view = makeView();
|
||||
view.spec.panelType = SavedviewtypesPanelTypeDTO.graph;
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
// panelType travels separately (url param), the Query itself has no such field
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
49
frontend/src/container/SavedViews/utils.ts
Normal file
49
frontend/src/container/SavedViews/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.version-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.version-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { deleteView } from 'api/saveView/deleteView';
|
||||
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||
|
||||
export const useDeleteView = (
|
||||
uuid: string,
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> =>
|
||||
useMutation({
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [uuid],
|
||||
mutationFn: () => deleteView(uuid),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { saveView } from 'api/saveView/saveView';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
|
||||
@@ -13,8 +14,14 @@ export const useSaveView = ({
|
||||
Error,
|
||||
SaveViewProps,
|
||||
SaveViewPayloadProps
|
||||
> =>
|
||||
useMutation({
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: saveView,
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { updateView } from 'api/saveView/updateView';
|
||||
import {
|
||||
UpdateViewPayloadProps,
|
||||
@@ -16,8 +17,10 @@ export const useUpdateView = ({
|
||||
Error,
|
||||
UpdateViewProps,
|
||||
UpdateViewPayloadProps
|
||||
> =>
|
||||
useMutation({
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: () =>
|
||||
updateView({
|
||||
@@ -27,4 +30,8 @@ export const useUpdateView = ({
|
||||
sourcePage,
|
||||
viewKey,
|
||||
}),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
11
frontend/src/hooks/useSavedViewEnabled.ts
Normal file
11
frontend/src/hooks/useSavedViewEnabled.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useSavedViewEnabled(): boolean {
|
||||
const [isEnabled] = useState(
|
||||
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
|
||||
);
|
||||
|
||||
return isEnabled;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export const fieldSearchFilter = (
|
||||
searchSpace = '',
|
||||
currentValue = '',
|
||||
): boolean => {
|
||||
if (!currentValue || !searchSpace) {
|
||||
return true;
|
||||
}
|
||||
return searchSpace.toLowerCase().indexOf(currentValue.toLowerCase()) !== -1;
|
||||
};
|
||||
@@ -1,4 +1,29 @@
|
||||
.alerts-container {
|
||||
// Hands the page height down to the active tab so its content can bound itself
|
||||
// instead of guessing with 100vh. Child combinators only, nested Tabs
|
||||
// (Configuration) must not be caught.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .ant-tabs-content-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-level-tab.periscope-tab {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -40,5 +65,9 @@
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
@@ -164,10 +164,10 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
'http://localhost/api/v2/saved_views',
|
||||
response.json((req) => {
|
||||
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
|
||||
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
|
||||
const source = req.url.searchParams.get('source') ?? 'logs';
|
||||
const signal = isSavedViewSignal(source) ? source : 'logs';
|
||||
|
||||
return savedViewsResponse(
|
||||
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
|
||||
|
||||
@@ -6,10 +6,21 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type ListSavedViews200,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal as LogsSignal,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal as TracesSignal,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
type Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
type RuletypesRuleDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
|
||||
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
@@ -165,20 +176,53 @@ const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
|
||||
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
|
||||
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
|
||||
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSignal, SavedviewtypesSourceDTO> = {
|
||||
logs: SavedviewtypesSourceDTO.logs,
|
||||
traces: SavedviewtypesSourceDTO.traces,
|
||||
metrics: SavedviewtypesSourceDTO.metrics,
|
||||
};
|
||||
|
||||
const SAVED_VIEW_QUERY: Record<
|
||||
SavedViewSignal,
|
||||
Querybuildertypesv5QueryEnvelopeDTO
|
||||
> = {
|
||||
logs: {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: { name: 'A', signal: LogsSignal.logs },
|
||||
},
|
||||
traces: {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: { name: 'A', signal: TracesSignal.traces },
|
||||
},
|
||||
metrics: {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: { name: 'A', signal: MetricsSignal.metrics },
|
||||
},
|
||||
};
|
||||
|
||||
export const savedViewsResponse = (
|
||||
count: number,
|
||||
sourcePage: SavedViewSignal,
|
||||
): Record<string, unknown> => {
|
||||
const names = VIEW_NAMES[sourcePage];
|
||||
signal: SavedViewSignal,
|
||||
): ListSavedViews200 => {
|
||||
const names = VIEW_NAMES[signal];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
|
||||
...explorerView.data[0],
|
||||
id: `storybook-${sourcePage}-view-${index + 1}`,
|
||||
name: names[index],
|
||||
sourcePage,
|
||||
tags: [sourcePage],
|
||||
id: `storybook-${signal}-view-${index + 1}`,
|
||||
name: `storybook-${signal}-view-${index + 1}`,
|
||||
source: SAVED_VIEW_SOURCE[signal],
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdAt: '2026-08-20T09:00:00Z',
|
||||
createdBy: 'storybook@signoz.io',
|
||||
updatedAt: '2026-08-20T09:00:00Z',
|
||||
updatedBy: 'storybook@signoz.io',
|
||||
spec: {
|
||||
displayName: names[index],
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
queries: [SAVED_VIEW_QUERY[signal]],
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.support-page-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.support-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.root {
|
||||
height: calc(100vh);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
height: 100%;
|
||||
|
||||
&__steps-config {
|
||||
flex-shrink: 0;
|
||||
width: 600px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
// Positioning context for the absolute .steps-footer.
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Scoped here so the modal usage of FunnelConfiguration on trace details
|
||||
// stays in normal flow.
|
||||
.funnel-configuration {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
&__steps-results {
|
||||
width: 100%;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user