Compare commits

..

14 Commits

Author SHA1 Message Date
Nikhil Soni
a57b877a7f chore: change tests to reflect non-normalised support for scope
For scope name and version fields
2026-05-14 17:21:54 +05:30
Nikhil Soni
d0370ce3ef fix: handle fields with included context for scope (select clause) 2026-05-14 17:02:56 +05:30
Nikhil Soni
d169761e65 Merge remote-tracking branch 'origin/main' into ns/scope 2026-05-14 11:50:33 +05:30
Nikhil Soni
87864ef5d4 chore: remove duplicates from .gitignore 2026-05-11 15:45:32 +05:30
Nikhil Soni
2e0bc8998e chore: use name as key name for scope instead of scope.name 2026-05-11 15:40:45 +05:30
Nikhil Soni
7e1f4aa50d Merge remote-tracking branch 'origin/main' into ns/scope 2026-05-11 14:27:19 +05:30
Nikhil Soni
35da39247c Merge branch 'main' into ns/scope 2026-05-07 17:41:11 +05:30
Nikhil Soni
ceccc47a34 fix: fix test for case without resource filter 2026-05-07 16:04:03 +05:30
Nikhil Soni
23da5e22ec Merge branch 'main' into ns/scope 2026-05-07 13:27:34 +05:30
Nikhil Soni
4c1b479149 chore: add tests for scope fields 2026-04-28 20:27:10 +05:30
Nikhil Soni
f72204a8b2 refactor: simplify field mapper for scope 2026-04-28 20:26:37 +05:30
Nikhil Soni
deb3f385fa chore: remove underscore version of scope fields 2026-04-23 10:26:55 +05:30
Nikhil Soni
77ce5f86b1 fix: use scope as json field instead with name and version 2026-04-23 01:15:02 +05:30
Nikhil Soni
ff211de441 feat: add support for scope fields in traces 2026-04-14 10:45:08 +05:30
129 changed files with 3554 additions and 12866 deletions

3
.gitignore vendored
View File

@@ -231,4 +231,5 @@ cython_debug/
# LSP config files
pyrightconfig.json
# agents
*settings.local.json

View File

@@ -66,9 +66,10 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesRole).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -3,7 +3,7 @@ FROM node:22-bookworm AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 npm i -g pnpm@10
RUN CI=1 npm i -g pnpm
RUN CI=1 pnpm install
RUN CI=1 pnpm build

View File

@@ -449,7 +449,6 @@ components:
- list
- assignee
- attach
- detach
type: string
AuthtypesRole:
properties:
@@ -2207,7 +2206,7 @@ components:
- role
- organization
- metaresource
- telemetryresource
- metaresources
type: string
DashboardtypesDashboard:
properties:
@@ -2729,82 +2728,6 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesJobRecord:
properties:
activePods:
type: integer
desiredSuccessfulPods:
type: integer
failedPods:
type: integer
jobCPU:
format: double
type: number
jobCPULimit:
format: double
type: number
jobCPURequest:
format: double
type: number
jobMemory:
format: double
type: number
jobMemoryLimit:
format: double
type: number
jobMemoryRequest:
format: double
type: number
jobName:
type: string
meta:
additionalProperties:
type: string
nullable: true
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
successfulPods:
type: integer
required:
- jobName
- jobCPU
- jobCPURequest
- jobCPULimit
- jobMemory
- jobMemoryRequest
- jobMemoryLimit
- desiredSuccessfulPods
- activePods
- failedPods
- successfulPods
- podCountsByPhase
- meta
type: object
InframonitoringtypesJobs:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesNamespaceRecord:
properties:
meta:
@@ -3108,32 +3031,6 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableJobs:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostableNamespaces:
properties:
end:
@@ -9297,9 +9194,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- role:list
- ADMIN
- tokenizer:
- role:list
- ADMIN
summary: List roles
tags:
- role
@@ -9371,9 +9268,9 @@ paths:
description: Not Implemented
security:
- api_key:
- role:create
- ADMIN
- tokenizer:
- role:create
- ADMIN
summary: Create role
tags:
- role
@@ -9433,9 +9330,9 @@ paths:
description: Not Implemented
security:
- api_key:
- role:delete
- ADMIN
- tokenizer:
- role:delete
- ADMIN
summary: Delete role
tags:
- role
@@ -9484,9 +9381,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- role:read
- ADMIN
- tokenizer:
- role:read
- ADMIN
summary: Get role
tags:
- role
@@ -9550,9 +9447,9 @@ paths:
description: Not Implemented
security:
- api_key:
- role:update
- ADMIN
- tokenizer:
- role:update
- ADMIN
summary: Patch role
tags:
- role
@@ -9628,9 +9525,9 @@ paths:
description: Not Implemented
security:
- api_key:
- role:read
- ADMIN
- tokenizer:
- role:read
- ADMIN
summary: Get objects for a role by relation
tags:
- role
@@ -9706,9 +9603,9 @@ paths:
description: Not Implemented
security:
- api_key:
- role:update
- ADMIN
- tokenizer:
- role:update
- ADMIN
summary: Patch objects for a role by relation
tags:
- role
@@ -10312,9 +10209,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- factor-api-key:list
- serviceaccount:read
- tokenizer:
- factor-api-key:list
- serviceaccount:read
summary: List service account keys
tags:
- serviceaccount
@@ -10380,11 +10277,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- factor-api-key:create
- serviceaccount:attach
- serviceaccount:update
- tokenizer:
- factor-api-key:create
- serviceaccount:attach
- serviceaccount:update
summary: Create a service account key
tags:
- serviceaccount
@@ -10437,11 +10332,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- factor-api-key:delete
- serviceaccount:detach
- serviceaccount:update
- tokenizer:
- factor-api-key:delete
- serviceaccount:detach
- serviceaccount:update
summary: Revoke a service account key
tags:
- serviceaccount
@@ -10504,9 +10397,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- factor-api-key:update
- serviceaccount:update
- tokenizer:
- factor-api-key:update
- serviceaccount:update
summary: Updates a service account key
tags:
- serviceaccount
@@ -10678,11 +10571,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:detach
- role:detach
- serviceaccount:attach
- role:attach
summary: Delete service account role
tags:
- serviceaccount
@@ -12418,84 +12311,6 @@ paths:
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/jobs:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes Jobs with key aggregated
pod metrics: CPU usage and memory working set summed across pods owned by
the job, plus average CPU/memory request and limit utilization (jobCPURequest,
jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the
latest known job-level counters from kube-state-metrics: desiredSuccessfulPods
(k8s.job.desired_successful_pods, the target completion count), activePods
(k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across
the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative).
It also reports per-group podCountsByPhase ({ pending, running, succeeded,
failed, unknown } from each pod''s latest k8s.pod.phase value); note podCountsByPhase.failed
(current pod-phase) is distinct from failedPods (cumulative job kube-state-metric).
Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name).
The response type is ''list'' for the default k8s.job.name grouping or ''grouped_list''
for custom groupBy keys; in both modes every row aggregates pods owned by
jobs in the group. Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
/ desired_successful_pods / active_pods / failed_pods / successful_pods, and
pagination via offset/limit. Also reports missing required metrics and whether
the requested time range falls before the data retention boundary. Numeric
metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest,
jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods)
return -1 as a sentinel when no data is available for that field.'
operationId: ListJobs
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableJobs'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesJobs'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/namespaces:
post:
deprecated: false

View File

@@ -87,7 +87,7 @@ func (provider *provider) BatchCheck(ctx context.Context, tupleReq map[string]*o
}
func (provider *provider) CheckTransactions(ctx context.Context, subject string, orgID valuer.UUID, transactions []*authtypes.Transaction) ([]*authtypes.TransactionWithAuthorization, error) {
tuples, correlations, err := authtypes.NewTuplesFromTransactionsWithCorrelations(transactions, subject, orgID)
tuples, err := authtypes.NewTuplesFromTransactions(transactions, subject, orgID)
if err != nil {
return nil, err
}
@@ -99,21 +99,10 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
results := make([]*authtypes.TransactionWithAuthorization, len(transactions))
for i, txn := range transactions {
txnID := txn.ID.StringValue()
authorized := batchResults[txnID].Authorized
if !authorized {
for _, correlationID := range correlations[txnID] {
if result, exists := batchResults[correlationID]; exists && result.Authorized {
authorized = true
break
}
}
}
result := batchResults[txn.ID.StringValue()]
results[i] = &authtypes.TransactionWithAuthorization{
Transaction: txn,
Authorized: authorized,
Authorized: result.Authorized,
}
}
return results, nil

View File

@@ -7,27 +7,17 @@ type organization
type user
relations
define create: [user, serviceaccount, role#assignee]
define list: [user, serviceaccount, role#assignee]
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
define detach: [user, serviceaccount, role#assignee]
type serviceaccount
relations
define create: [user, serviceaccount, role#assignee]
define list: [user, serviceaccount, role#assignee]
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
define detach: [user, serviceaccount, role#assignee]
type anonymous
@@ -35,28 +25,25 @@ type role
relations
define assignee: [user, serviceaccount, anonymous]
define create: [user, serviceaccount, role#assignee]
define list: [user, serviceaccount, role#assignee]
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
define detach: [user, serviceaccount, role#assignee]
type metaresource
type metaresources
relations
define create: [user, serviceaccount, role#assignee]
define list: [user, serviceaccount, role#assignee]
define read: [user, serviceaccount, anonymous, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
type metaresource
relations
define read: [user, serviceaccount, anonymous, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define block: [user, serviceaccount, role#assignee]
define block: [user, serviceaccount, role#assignee]
type telemetryresource
relations
define read: [user, serviceaccount, role#assignee]
define read: [user, serviceaccount, role#assignee]

View File

@@ -5,15 +5,9 @@ cd frontend && pnpm run commitlint --edit $1
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ -n "$TERM" ] && [ "$TERM" != "dumb" ]; then
color_red="$(tput setaf 1)"
bold="$(tput bold)"
reset="$(tput sgr0)"
else
color_red=""
bold=""
reset=""
fi
color_red="$(tput setaf 1)"
bold="$(tput bold)"
reset="$(tput sgr0)"
if [ "$branch" = "main" ]; then
echo "${color_red}${bold}You can't commit directly to the main branch${reset}"

View File

@@ -1,5 +1,4 @@
registry = 'https://registry.npmjs.org/'
engine-strict=true
public-hoist-pattern[]=@commitlint*
public-hoist-pattern[]=commitlint

View File

@@ -4,7 +4,6 @@
"description": "",
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
"dev": "vite",
"build": "vite build",
@@ -27,8 +26,7 @@
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
},
"engines": {
"node": ">=22.0.0",
"pnpm": ">=10.0.0 <11.0.0"
"node": ">=22.0.0"
},
"author": "",
"license": "ISC",
@@ -53,7 +51,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.19",
"@signozhq/ui": "0.0.18",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -89,8 +89,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.19
version: 0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)
specifier: 0.0.18
version: 0.0.18(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -1907,105 +1907,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2360,56 +2344,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
@@ -2512,56 +2488,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.62.0':
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.62.0':
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.62.0':
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.62.0':
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.62.0':
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.62.0':
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
@@ -2616,42 +2584,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -3512,28 +3474,24 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
@@ -3686,8 +3644,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.19':
resolution: {integrity: sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==}
'@signozhq/ui@0.0.18':
resolution: {integrity: sha512-1p3ALh76kafiz5yX7ReNKVcHDt2od7CcZD/Vx9i2adTwTeynkLJcEfVoXoJD3oh1kKTleooOiOjRyxlA7VzmSA==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -4308,49 +4266,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4417,7 +4367,7 @@ packages:
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
vite: npm:rolldown-vite@7.3.1
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -7244,28 +7194,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -10293,7 +10239,7 @@ packages:
oxlint: '>=1'
stylelint: '>=16'
typescript: '*'
vite: '>=5.4.21'
vite: npm:rolldown-vite@7.3.1
vls: '*'
vti: '*'
vue-tsc: ~2.2.10 || ^3.0.0
@@ -10322,12 +10268,12 @@ packages:
vite-plugin-compression@0.5.1:
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
peerDependencies:
vite: '>=2.0.0'
vite: npm:rolldown-vite@7.3.1
vite-plugin-html@3.2.2:
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
peerDependencies:
vite: '>=2.0.0'
vite: npm:rolldown-vite@7.3.1
vite-plugin-image-optimizer@2.0.3:
resolution: {integrity: sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A==}
@@ -10335,7 +10281,7 @@ packages:
peerDependencies:
sharp: '>=0.34.0'
svgo: '>=4'
vite: '>=5'
vite: npm:rolldown-vite@7.3.1
peerDependenciesMeta:
sharp:
optional: true
@@ -10345,7 +10291,7 @@ packages:
vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies:
vite: '*'
vite: npm:rolldown-vite@7.3.1
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
@@ -13955,7 +13901,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.18(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)

View File

@@ -26,6 +26,5 @@
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
"your_graph_build_with": "Your graph built with",
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
"dashboard_ok_confirm": "query will be saved. Press OK to confirm."
}

View File

@@ -30,6 +30,5 @@
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
"your_graph_build_with": "Your graph built with",
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
"dashboard_ok_confirm": "query will be saved. Press OK to confirm."
}

View File

@@ -15,7 +15,6 @@ import type {
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableJobsDTO,
InframonitoringtypesPostableNamespacesDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
@@ -24,7 +23,6 @@ import type {
ListClusters200,
ListDeployments200,
ListHosts200,
ListJobs200,
ListNamespaces200,
ListNodes200,
ListPods200,
@@ -288,90 +286,6 @@ export const useListHosts = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
* @summary List Jobs for Infra Monitoring
*/
export const listJobs = (
inframonitoringtypesPostableJobsDTO: BodyType<InframonitoringtypesPostableJobsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListJobs200>({
url: `/api/v2/infra_monitoring/jobs`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableJobsDTO,
signal,
});
};
export const getListJobsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listJobs>>,
TError,
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listJobs>>,
TError,
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
TContext
> => {
const mutationKey = ['listJobs'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listJobs>>,
{ data: BodyType<InframonitoringtypesPostableJobsDTO> }
> = (props) => {
const { data } = props ?? {};
return listJobs(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListJobsMutationResult = NonNullable<
Awaited<ReturnType<typeof listJobs>>
>;
export type ListJobsMutationBody =
BodyType<InframonitoringtypesPostableJobsDTO>;
export type ListJobsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Jobs for Infra Monitoring
*/
export const useListJobs = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listJobs>>,
TError,
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listJobs>>,
TError,
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
TContext
> => {
const mutationOptions = getListJobsMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* @summary List Namespaces for Infra Monitoring

View File

@@ -1840,7 +1840,6 @@ export enum AuthtypesRelationDTO {
list = 'list',
assignee = 'assignee',
attach = 'attach',
detach = 'detach',
}
export interface AuthtypesRoleDTO {
/**
@@ -4162,7 +4161,7 @@ export enum CoretypesTypeDTO {
role = 'role',
organization = 'organization',
metaresource = 'metaresource',
telemetryresource = 'telemetryresource',
metaresources = 'metaresources',
}
export interface DashboardtypesDashboardDTO {
/**
@@ -4791,91 +4790,6 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
/**
* @nullable
*/
export type InframonitoringtypesJobRecordDTOMeta = {
[key: string]: string;
} | null;
export interface InframonitoringtypesJobRecordDTO {
/**
* @type integer
*/
activePods: number;
/**
* @type integer
*/
desiredSuccessfulPods: number;
/**
* @type integer
*/
failedPods: number;
/**
* @type number
* @format double
*/
jobCPU: number;
/**
* @type number
* @format double
*/
jobCPULimit: number;
/**
* @type number
* @format double
*/
jobCPURequest: number;
/**
* @type number
* @format double
*/
jobMemory: number;
/**
* @type number
* @format double
*/
jobMemoryLimit: number;
/**
* @type number
* @format double
*/
jobMemoryRequest: number;
/**
* @type string
*/
jobName: string;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
/**
* @type integer
*/
successfulPods: number;
}
export interface InframonitoringtypesJobsDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesJobRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
/**
* @nullable
*/
@@ -5192,34 +5106,6 @@ export interface InframonitoringtypesPostableHostsDTO {
start: number;
}
export interface InframonitoringtypesPostableJobsDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableNamespacesDTO {
/**
* @type integer
@@ -9849,14 +9735,6 @@ export type ListHosts200 = {
status: string;
};
export type ListJobs200 = {
data: InframonitoringtypesJobsDTO;
/**
* @type string
*/
status: string;
};
export type ListNamespaces200 = {
data: InframonitoringtypesNamespacesDTO;
/**

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot, Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import {
@@ -97,7 +97,7 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title="AI Assistant">
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="secondary"
@@ -113,7 +113,7 @@ function HeaderRightSection({
>
AI Assistant
</Button>
</TooltipSimple>
</Tooltip>
</div>
)}

View File

@@ -1155,7 +1155,7 @@ describe('removeKeysFromExpression', () => {
});
describe('Real-world scenarios', () => {
it('should remove at most one variable expression per key', () => {
it('should handle multiple variable instances of same key', () => {
const expression =
"deployment.environment = $env1 deployment.environment = $env2 deployment.environment = 'default'";
const result = removeKeysFromExpression(
@@ -1164,11 +1164,9 @@ describe('removeKeysFromExpression', () => {
true,
);
// Should remove one occurrence — having multiple $-value clauses for the
// same key is invalid. The first is removed; subsequent $ clauses and
// literal-value clauses are preserved.
// Should remove one occurence as this case in itself is invalid to have multiple variable expressions for the same key
expect(result).toBe(
"deployment.environment = $env2 AND deployment.environment = 'default'",
"deployment.environment = $env1 deployment.environment = 'default'",
);
});
@@ -1201,186 +1199,6 @@ describe('removeKeysFromExpression', () => {
expect(pairs).toHaveLength(2);
});
});
describe('ANTLR-based removal — operator precedence (AND binds tighter than OR)', () => {
it('preserves OR when removing from a mixed AND/OR expression', () => {
// a AND b OR c — grammar parses as (a AND b) OR c
// removing b collapses the AND group to just a, OR is preserved
expect(
removeKeysFromExpression("a = '1' AND b = '2' OR c = '3'", ['b']),
).toBe("a = '1' OR c = '3'");
});
it('preserves correct conjunctions in a four-term mixed expression', () => {
// a AND b OR c AND d — removing b collapses first AND group to a
expect(
removeKeysFromExpression("a = '1' AND b = '2' OR c = '3' AND d = '4'", [
'b',
]),
).toBe("a = '1' OR c = '3' AND d = '4'");
});
it('preserves correct conjunctions when removing from a trailing AND group', () => {
// a OR b AND c OR d — removing c collapses second AND group to b
expect(
removeKeysFromExpression("a = '1' OR b = '2' AND c = '3' OR d = '4'", [
'c',
]),
).toBe("a = '1' OR b = '2' OR d = '4'");
});
});
describe('ANTLR-based removal — parenthesised expressions', () => {
it('removes last clause without leaving a dangling AND', () => {
const expression =
'(deployment.environment = $deployment.environment AND service.name = $service.name AND top_level_operation IN [$top_level_operation])';
expect(
removeKeysFromExpression(expression, ['top_level_operation'], true),
).toBe(
'(deployment.environment = $deployment.environment AND service.name = $service.name)',
);
});
it('removes first clause without leaving a dangling AND', () => {
expect(
removeKeysFromExpression(
'(deployment.environment = $deployment.environment AND service.name = $service.name)',
['deployment.environment'],
true,
),
).toBe('service.name = $service.name');
});
it('removes middle clause without disturbing surrounding AND', () => {
expect(
removeKeysFromExpression(
'(deployment.environment = $deployment.environment AND service.name = $service.name AND region = $region)',
['service.name'],
true,
),
).toBe(
'(deployment.environment = $deployment.environment AND region = $region)',
);
});
it('drops the empty paren group when its only child is removed', () => {
// (a) OR (b) — removing a must not leave () OR (b = '2')
// The remaining single-clause group has its redundant parens stripped too
expect(removeKeysFromExpression("(a = '1') OR (b = '2')", ['a'])).toBe(
"b = '2'",
);
});
it('handles OR inside parentheses without leaving dangling OR', () => {
expect(
removeKeysFromExpression(
'(service.name = $service.name OR operation = $operation)',
['operation'],
true,
),
).toBe('service.name = $service.name');
});
});
describe('ANTLR-based removal — BETWEEN, EXISTS, and other operators', () => {
it('removes a BETWEEN clause without treating its AND as a conjunction', () => {
// BETWEEN x AND y — the AND is part of the operator, not a conjunction
expect(
removeKeysFromExpression("a BETWEEN 1 AND 10 AND b = '2'", ['a']),
).toBe("b = '2'");
});
it('removes a NOT BETWEEN clause without treating its AND as a conjunction', () => {
expect(
removeKeysFromExpression("a NOT BETWEEN 1 AND 10 AND b = '2'", ['a']),
).toBe("b = '2'");
});
it('removes an EXISTS clause (no value token)', () => {
expect(removeKeysFromExpression("a = '1' AND b EXISTS", ['b'])).toBe(
"a = '1'",
);
});
it('removes a NOT EXISTS clause', () => {
expect(removeKeysFromExpression("a = '1' AND b NOT EXISTS", ['b'])).toBe(
"a = '1'",
);
});
it('removes an IN clause correctly', () => {
expect(
removeKeysFromExpression("service IN ['api', 'web'] AND status = 'ok'", [
'service',
]),
).toBe("status = 'ok'");
});
it('removes a NOT IN clause correctly', () => {
expect(
removeKeysFromExpression(
"service NOT IN ['api', 'web'] AND status = 'ok'",
['service'],
),
).toBe("status = 'ok'");
});
it('removes a CONTAINS clause correctly', () => {
expect(
removeKeysFromExpression("message CONTAINS 'error' AND service = 'api'", [
'message',
]),
).toBe("service = 'api'");
});
it('removes a LIKE clause correctly', () => {
expect(
removeKeysFromExpression("message LIKE '%error%' AND service = 'api'", [
'message',
]),
).toBe("service = 'api'");
});
it('removes a NOT LIKE clause correctly', () => {
expect(
removeKeysFromExpression("message NOT LIKE '%error%' AND service = 'api'", [
'message',
]),
).toBe("service = 'api'");
});
});
describe('ANTLR-based removal — AND/OR precedence combinations', () => {
it('handles a AND b AND c OR d: removing b leaves a AND c OR d', () => {
// AND binds tighter than OR, so this parses as (a AND b AND c) OR d
expect(
removeKeysFromExpression("a = '1' AND b = '2' AND c = '3' OR d = '4'", [
'b',
]),
).toBe("a = '1' AND c = '3' OR d = '4'");
});
});
describe('ANTLR-based removal — deeply nested expressions', () => {
const nestedExpr =
"((deployment.environment = $env1 OR deployment.environment = 'default') AND service.name = $svc1)";
it('removes service.name variable — outer and inner single-child parens both drop', () => {
// After removal: inner OR group keeps parens (2 items), outer group drops
// parens (1 item remains)
expect(removeKeysFromExpression(nestedExpr, ['service.name'], true)).toBe(
"(deployment.environment = $env1 OR deployment.environment = 'default')",
);
});
it('removes deployment.environment variable — inner OR collapses, outer parens kept', () => {
// Only the $env1 variable clause is removed; 'default' literal stays.
// Inner paren drops (single item left), outer paren stays (2 AND items remain).
expect(
removeKeysFromExpression(nestedExpr, ['deployment.environment'], true),
).toBe("(deployment.environment = 'default' AND service.name = $svc1)");
});
});
});
describe('formatValueForExpression', () => {

View File

@@ -1,7 +1,4 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { CharStreams, CommonTokenStream, ParserRuleContext } from 'antlr4';
import { cloneDeep, isEqual, sortBy } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import {
DEPRECATED_OPERATORS_MAP,
@@ -9,16 +6,7 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,
ComparisonContext,
InClauseContext,
NotInClauseContext,
OrExpressionContext,
PrimaryContext,
UnaryExpressionContext,
} from 'parser/FilterQueryParser';
import { cloneDeep, isEqual, sortBy } from 'lodash-es';
import { IQueryPair } from 'types/antlrQueryTypes';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -38,6 +26,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
import { v4 as uuid } from 'uuid';
/**
* Check if an operator requires array values (like IN, NOT IN)
@@ -524,201 +513,97 @@ export const convertFiltersToExpressionWithExistingQuery = (
};
/**
* Removes clauses for specified keys from a filter query expression.
* Removes specified key-value pairs from a logical query expression string.
*
* Uses an ANTLR parse-tree traversal over the existing FilterQuery grammar so that
* compound predicates like `BETWEEN x AND y`, `EXISTS`, and `IN (...)` are treated
* as atomic nodes — their internal tokens are never confused with top-level AND/OR
* conjunctions. Surviving siblings are rejoined with the correct operator at each
* level of the tree, producing no dangling operators regardless of expression shape.
* If the expression cannot be parsed, it is returned unchanged.
* This function parses the given query expression and removes any query pairs
* whose keys match those in the `keysToRemove` array. It also removes any trailing
* logical conjunctions (e.g., `AND`, `OR`) and whitespace that follow the matched pairs,
* ensuring that the resulting expression remains valid and clean.
*
* @param expression - The full filter query string.
* @param keysToRemove - Keys (case-insensitive) whose clauses should be dropped.
* @param removeOnlyVariableExpressions - Controls which clauses are eligible for removal:
* - `false` (default): removes all clauses for the key regardless of value.
* - `true`: removes only the first clause whose value contains any `$`.
* - `string` (e.g. `"$service.name"`): removes only the clause whose value exactly
* matches that string — preferred when the specific variable reference is known.
* @returns The rewritten expression, or an empty string if all clauses were removed.
* @param expression - The full query string.
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
* @param removeOnlyVariableExpressions - When true, only removes key-value pairs where the value is a variable (starts with $). When false, uses the original behavior.
* @returns A new expression string with the specified keys and their associated clauses removed.
*/
export const removeKeysFromExpression = (
expression: string,
keysToRemove: string[],
removeOnlyVariableExpressions: string | boolean = false,
removeOnlyVariableExpressions = false,
): string => {
if (!keysToRemove || keysToRemove.length === 0) {
return expression;
}
if (!expression.trim()) {
return expression;
}
const keysSet = new Set(keysToRemove.map((k) => k.trim().toLowerCase()));
// Tracks keys for which a variable expression has already been removed.
// Having multiple $-value clauses for the same key is invalid; we remove at most one.
const removedVariableKeys = new Set<string>();
let updatedExpression = expression;
const chars = CharStreams.fromString(expression);
const lexer = new FilterQueryLexer(chars);
lexer.removeErrorListeners();
const tokenStream = new CommonTokenStream(lexer);
const parser = new FilterQueryParser(tokenStream);
parser.removeErrorListeners();
if (updatedExpression) {
keysToRemove.forEach((key) => {
// Extract key-value query pairs from the expression
const existingQueryPairs = extractQueryPairs(updatedExpression);
const tree = parser.query();
let queryPairsMap: Map<string, IQueryPair>;
// If the expression couldn't be parsed, return it unchanged rather than mangling it
if (parser.syntaxErrorsCount > 0) {
return expression;
}
if (existingQueryPairs.length > 0) {
// Filter query pairs based on the removeOnlyVariableExpressions flag
const filteredQueryPairs = removeOnlyVariableExpressions
? existingQueryPairs.filter((pair) => {
const pairKey = pair.key?.trim().toLowerCase();
const matchesKey = pairKey === `${key}`.trim().toLowerCase();
if (!matchesKey) {
return false;
}
const value = pair.value?.toString().trim();
return value && value.includes('$');
})
: existingQueryPairs;
// Extract original source text for a node, preserving the user's exact formatting
const src = (ctx: ParserRuleContext): string =>
expression.slice(ctx.start.start, (ctx.stop ?? ctx.start).stop + 1);
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
queryPairsMap = new Map(
filteredQueryPairs.map((pair) => {
const key = pair.key.trim().toLowerCase();
return [key, pair];
}),
);
// Returns null when the entire node should be dropped.
// isSingle = true means the result is a single, non-compound expression at
// this level (no AND/OR between sibling clauses), which lets the paren
// visitor decide whether wrapping is still needed.
type VisitResult = { text: string; isSingle: boolean } | null;
function visitOrExpression(ctx: OrExpressionContext): VisitResult {
const parts = ctx
.andExpression_list()
.map(visitAndExpression)
.filter((r): r is NonNullable<VisitResult> => r !== null);
if (parts.length === 0) {
return null;
}
// Single surviving branch — pass its isSingle straight through so the
// paren visitor can decide whether to keep the outer parens.
if (parts.length === 1) {
return parts[0];
}
return { text: parts.map((p) => p.text).join(' OR '), isSingle: false };
}
function visitAndExpression(ctx: AndExpressionContext): VisitResult {
const parts = ctx
.unaryExpression_list()
.map(visitUnaryExpression)
.filter((r): r is NonNullable<VisitResult> => r !== null);
if (parts.length === 0) {
return null;
}
if (parts.length === 1) {
return { text: parts[0].text, isSingle: true };
}
return { text: parts.map((p) => p.text).join(' AND '), isSingle: false };
}
function visitUnaryExpression(ctx: UnaryExpressionContext): VisitResult {
const primaryResult = visitPrimary(ctx.primary());
if (primaryResult === null) {
return null;
}
return ctx.NOT()
? { text: `NOT ${primaryResult.text}`, isSingle: true }
: primaryResult;
}
function visitPrimary(ctx: PrimaryContext): VisitResult {
// Parenthesised sub-expression: ( orExpression )
const orCtx = ctx.orExpression();
if (orCtx) {
const inner = visitOrExpression(orCtx);
if (inner === null) {
return null;
// Lookup the current query pair using the attribute key (case-insensitive)
const currentQueryPair = queryPairsMap.get(`${key}`.trim().toLowerCase());
if (currentQueryPair && currentQueryPair.isComplete) {
// Determine the start index of the query pair (fallback order: key → operator → value)
const queryPairStart =
currentQueryPair.position.keyStart ??
currentQueryPair.position.operatorStart ??
currentQueryPair.position.valueStart;
// Determine the end index of the query pair (fallback order: value → operator → key)
let queryPairEnd =
currentQueryPair.position.valueEnd ??
currentQueryPair.position.operatorEnd ??
currentQueryPair.position.keyEnd;
// Get the part of the expression that comes after the current query pair
const expressionAfterPair = `${updatedExpression.slice(queryPairEnd + 1)}`;
// Match optional spaces and an optional conjunction (AND/OR), case-insensitive
const conjunctionOrSpacesRegex = /^(\s*((AND|OR)\s+)?)/i;
const match = expressionAfterPair.match(conjunctionOrSpacesRegex);
if (match && match.length > 0) {
// If match is found, extend the queryPairEnd to include the matched part
queryPairEnd += match[0].length;
}
// Remove the full query pair (including any conjunction/whitespace) from the expression
updatedExpression = `${updatedExpression.slice(
0,
queryPairStart,
)}${updatedExpression.slice(queryPairEnd + 1)}`.trim();
}
}
// Drop redundant parens when the group collapses to a single clause;
// keep them when multiple clauses remain (operator-precedence matters).
if (inner.isSingle) {
return { text: inner.text, isSingle: true };
}
return { text: `(${inner.text})`, isSingle: true };
}
});
const compCtx = ctx.comparison();
if (compCtx) {
const result = visitComparison(compCtx);
return result !== null ? { text: result, isSingle: true } : null;
}
// functionCall, fullText, bare key, bare value — keep verbatim
return { text: src(ctx), isSingle: true };
// Clean up any remaining trailing AND/OR operators and extra whitespace
updatedExpression = updatedExpression
.replace(/\s+(AND|OR)\s*$/i, '') // Remove trailing AND/OR
.replace(/^(AND|OR)\s+/i, '') // Remove leading AND/OR
.trim();
}
function visitComparison(ctx: ComparisonContext): string | null {
const keyText = ctx.key().getText().trim().toLowerCase();
if (!keysSet.has(keyText)) {
return src(ctx);
}
if (removeOnlyVariableExpressions) {
// Scope the value check to value nodes only — not the full comparison text —
// so a key that contains '$' does not trigger removal when the value is a
// literal. The ANTLR4 runtime returns null from getTypedRuleContext when a
// rule is absent, despite the non-nullable TypeScript signatures.
const inCtx = ctx.inClause() as unknown as InClauseContext | null;
const notInCtx = ctx.notInClause() as unknown as NotInClauseContext | null;
// When a specific variable string is supplied, require an exact match so we
// never accidentally remove a different $-valued clause for the same key.
const matchesVariable = (text: string): boolean =>
typeof removeOnlyVariableExpressions === 'string'
? text === removeOnlyVariableExpressions
: text.includes('$');
const valueHasVariable = (): boolean => {
// Simple comparisons: key = $var, BETWEEN $v1 AND $v2, etc.
if (ctx.value_list().some((v) => matchesVariable(v.getText()))) {
return true;
}
// IN $var (bare single value) or IN ($v1, $v2) (value list)
if (inCtx) {
const bare = inCtx.value() as unknown as { getText(): string } | null;
if (bare && matchesVariable(bare.getText())) {
return true;
}
const list = inCtx.valueList() as unknown as {
value_list(): { getText(): string }[];
} | null;
if (list && list.value_list().some((v) => matchesVariable(v.getText()))) {
return true;
}
}
// NOT IN $var or NOT IN ($v1, $v2)
if (notInCtx) {
const bare = notInCtx.value() as unknown as { getText(): string } | null;
if (bare && matchesVariable(bare.getText())) {
return true;
}
const list = notInCtx.valueList() as unknown as {
value_list(): { getText(): string }[];
} | null;
if (list && list.value_list().some((v) => matchesVariable(v.getText()))) {
return true;
}
}
return false;
};
if (valueHasVariable()) {
if (removedVariableKeys.has(keyText)) {
return src(ctx);
}
removedVariableKeys.add(keyText);
return null;
}
return src(ctx);
}
return null;
}
const result = visitOrExpression(tree.expression().orExpression());
return result?.text ?? '';
return updatedExpression;
};
/**

View File

@@ -360,7 +360,8 @@ describe('createGuardedRoute', () => {
const obj = payload[0]?.object;
const kind = obj?.resource?.kind;
const selector = obj?.selector ?? '*';
const objectStr = `${kind}:${selector}`;
const objectStr =
obj?.resource?.type === 'metaresources' ? kind : `${kind}:${selector}`;
requestedObjects.push(objectStr ?? '');
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
@@ -52,7 +52,7 @@ export default function AIAssistantDrawer(): JSX.Element {
</div>
<div>
<TooltipSimple title="New conversation">
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
@@ -62,9 +62,9 @@ export default function AIAssistantDrawer(): JSX.Element {
>
<Plus size={16} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Open full screen">
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
@@ -75,9 +75,9 @@ export default function AIAssistantDrawer(): JSX.Element {
>
<Maximize2 size={16} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Close">
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
@@ -87,7 +87,7 @@ export default function AIAssistantDrawer(): JSX.Element {
>
<X size={16} />
</Button>
</TooltipSimple>
</Tooltip>
</div>
</div>
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
@@ -132,7 +132,7 @@ export default function AIAssistantModal(): JSX.Element | null {
</div>
<div className={styles.actions}>
<TooltipSimple title={showHistory ? 'Back to chat' : 'Conversations'}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
@@ -142,9 +142,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<History size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="New conversation">
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
@@ -153,9 +153,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<Plus size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Open full screen">
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
@@ -165,9 +165,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<Maximize2 size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Minimize to side panel">
<Tooltip title="Minimize to side panel">
<Button
variant="ghost"
size="icon"
@@ -176,9 +176,9 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<Minus size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Close">
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
@@ -187,7 +187,7 @@ export default function AIAssistantModal(): JSX.Element | null {
>
<X size={14} />
</Button>
</TooltipSimple>
</Tooltip>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
@@ -125,7 +125,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
</div>
<div className={styles.actions}>
<TooltipSimple title={showHistory ? 'Back to chat' : 'Conversations'}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
@@ -135,9 +135,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<History size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="New conversation">
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
@@ -147,9 +147,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<Plus size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Open full screen">
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
@@ -160,9 +160,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<Maximize2 size={14} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Close">
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
@@ -172,7 +172,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
>
<X size={14} />
</Button>
</TooltipSimple>
</Tooltip>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
@@ -30,7 +30,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<TooltipSimple title="AI Assistant">
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="primary"
@@ -40,6 +40,6 @@ export default function AIAssistantTrigger(): JSX.Element | null {
>
<Bot size={20} />
</Button>
</TooltipSimple>
</Tooltip>
);
}

View File

@@ -12,7 +12,7 @@ import {
import cx from 'classnames';
import { v4 as uuidv4 } from 'uuid';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
@@ -524,9 +524,9 @@ export default function ActionsSection({
);
return tooltip ? (
<TooltipSimple key={key} title={tooltip}>
<Tooltip key={key} title={tooltip}>
{chip}
</TooltipSimple>
</Tooltip>
) : (
<span key={key}>{chip}</span>
);

View File

@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import {
getListRulesQueryKey,
@@ -899,7 +899,7 @@ export default function ChatInput({
</div>
</div>
) : (
<TooltipSimple title="Voice input">
<Tooltip title="Voice input">
<Button
variant="ghost"
size="icon"
@@ -910,11 +910,11 @@ export default function ChatInput({
>
<Mic size={14} />
</Button>
</TooltipSimple>
</Tooltip>
))}
{isStreaming && onCancel ? (
<TooltipSimple title="Stop generating">
<Tooltip title="Stop generating">
<Button
variant="solid"
size="icon"
@@ -924,7 +924,7 @@ export default function ChatInput({
>
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</TooltipSimple>
</Tooltip>
) : (
<Button
variant="solid"

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Plus, Search } from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
@@ -157,7 +157,7 @@ export default function ConversationsList({
{isLoadingThreads && <HeaderLoadingDots />}
{!isLoadingThreads && showAddNewConversation && (
<TooltipSimple title="New conversation">
<Tooltip title="New conversation">
<Button
variant="solid"
size="sm"
@@ -167,7 +167,7 @@ export default function ConversationsList({
>
<Plus size={12} />
</Button>
</TooltipSimple>
</Tooltip>
)}
</div>

View File

@@ -3,7 +3,7 @@ import cx from 'classnames';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
@@ -126,7 +126,7 @@ export default function MessageFeedback({
<>
<div className={cx(styles.feedback, { [styles.visible]: isLastAssistant })}>
<div className={styles.actions}>
<TooltipSimple title={copied ? 'Copied!' : 'Copy'}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
@@ -136,9 +136,9 @@ export default function MessageFeedback({
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Good response">
<Tooltip title="Good response">
<Button
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
size="icon"
@@ -148,9 +148,9 @@ export default function MessageFeedback({
>
<ThumbsUp size={12} />
</Button>
</TooltipSimple>
</Tooltip>
<TooltipSimple title="Bad response">
<Tooltip title="Bad response">
<Button
className={cx(styles.btn, {
[styles.votedDown]: vote === 'negative',
@@ -162,10 +162,10 @@ export default function MessageFeedback({
>
<ThumbsDown size={12} />
</Button>
</TooltipSimple>
</Tooltip>
{onRegenerate && (
<TooltipSimple title="Regenerate">
<Tooltip title="Regenerate">
<Button
className={styles.btn}
size="icon"
@@ -175,7 +175,7 @@ export default function MessageFeedback({
>
<RefreshCw size={12} />
</Button>
</TooltipSimple>
</Tooltip>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import { Message } from '../../types';
@@ -32,7 +32,7 @@ export default function UserMessageActions({
return (
<div className={styles.actions}>
<TooltipSimple title={copied ? 'Copied!' : 'Copy'}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
@@ -42,7 +42,7 @@ export default function UserMessageActions({
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
</Tooltip>
</div>
);
}

View File

@@ -1,7 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/ui/sonner';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { updateLocalStorageDashboardVariable } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
@@ -50,7 +48,6 @@ export const useDashboardVariableUpdate =
);
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
const updateMutation = useUpdateDashboard();
const { t } = useTranslation('dashboard');
const onValueUpdate = useCallback(
(
@@ -180,14 +177,6 @@ export const useDashboardVariableUpdate =
// Get current dashboard variables
const currentVariables = dashboardData.data.variables || {};
const nameExists = Object.values(currentVariables).some(
(v) => v.name === name,
);
if (nameExists) {
toast.error(t('variable_name_already_exists', { name, ns: 'dashboard' }));
return;
}
// Create tableRowData like Dashboard Settings does
const tableRowData = [];
const variableOrderArr = [];
@@ -213,20 +202,21 @@ export const useDashboardVariableUpdate =
// Create new variable
const nextOrder =
variableOrderArr.length > 0 ? Math.max(...variableOrderArr) + 1 : 0;
const newVariable: IDashboardVariable = {
const newVariable: any = {
id: uuidv4(),
name,
type: 'DYNAMIC',
type: 'DYNAMIC' as const,
description,
order: nextOrder,
selectedValue: value,
allSelected: false,
haveCustomValuesSelected: false,
sort: 'ASC',
sort: 'ASC' as const,
multiSelect: true,
showALLOption: true,
dynamicVariablesAttribute: name,
dynamicVariablesSource: source,
dynamicVariablesWidgetIds: [],
queryValue: '',
};

View File

@@ -1,5 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { TooltipSimple, TooltipProvider } from '@signozhq/ui/tooltip';
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
import { Copy } from '@signozhq/icons';
import './CopyIconButton.styles.scss';
@@ -20,7 +20,7 @@ function CopyIconButton({
return (
<TooltipProvider>
<TooltipSimple title={tooltipTitle}>
<Tooltip title={tooltipTitle}>
<span>
<Button
color="secondary"
@@ -33,7 +33,7 @@ function CopyIconButton({
onClick={onCopy}
/>
</span>
</TooltipSimple>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -4,8 +4,12 @@ export default {
data: {
resources: [
{
kind: 'factor-api-key',
type: 'metaresource',
kind: 'role',
type: 'metaresources',
},
{
kind: 'serviceaccount',
type: 'metaresources',
},
{
kind: 'role',
@@ -18,13 +22,12 @@ export default {
],
relations: {
assignee: ['role'],
attach: ['metaresource', 'role', 'serviceaccount'],
create: ['metaresource', 'role', 'serviceaccount'],
delete: ['metaresource', 'role', 'serviceaccount'],
detach: ['metaresource', 'role', 'serviceaccount'],
list: ['metaresource', 'role', 'serviceaccount'],
read: ['metaresource', 'role', 'serviceaccount'],
update: ['metaresource', 'role', 'serviceaccount'],
attach: ['role', 'serviceaccount'],
create: ['metaresources'],
delete: ['role', 'serviceaccount'],
list: ['metaresources'],
read: ['role', 'serviceaccount'],
update: ['role', 'serviceaccount'],
},
},
} as const;

View File

@@ -80,6 +80,19 @@ export function permissionToTransactionDto(
permission: BrandedPermission,
): AuthtypesTransactionDTO {
const { relation, object: objectStr } = parsePermission(permission);
const directType = resolveType(relation, objectStr);
if (directType === 'metaresources') {
return {
relation: relation as AuthtypesRelationDTO,
object: {
resource: {
kind: objectStr as ResourceName,
type: directType as CoretypesTypeDTO,
},
selector: '*',
},
};
}
const { resourceName, selector } = splitObjectString(objectStr);
const type = resolveType(relation, resourceName) ?? 'metaresource';
@@ -104,6 +117,9 @@ export function gettableTransactionToPermission(
} = item;
const resourceName = String(resource.kind);
const selectorStr = typeof selector === 'string' ? selector : '*';
const objectStr = `${resourceName}${ObjectSeparator}${selectorStr}`;
const objectStr =
resource.type === 'metaresources'
? resourceName
: `${resourceName}${ObjectSeparator}${selectorStr}`;
return `${relation}${PermissionSeparator}${objectStr}` as BrandedPermission;
}

View File

@@ -1,89 +0,0 @@
.body {
padding: 12px 6px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
// TabsRoot — last direct child div
> div:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
// Tabs library wrapper — scoped by `.body` so the global match is contained.
:global([class*='tabs__list-wrapper']) {
padding-left: 0 !important;
}
}
.tabsScroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
.list {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 4px 8px;
padding: 8px 0;
align-items: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
.serviceName {
font-size: 13px;
color: var(--l1-foreground);
word-break: break-word;
}
.barCell {
display: flex;
align-items: center;
gap: 8px;
}
.bar {
flex: 1;
height: 6px;
background: var(--l3-background);
border-radius: 3px;
min-width: 40px;
}
.barFill {
height: 100%;
border-radius: 3px;
}
.value {
flex-shrink: 0;
text-align: right;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
}
.valueWide {
min-width: 55px;
}
.valueNarrow {
min-width: 25px;
}

View File

@@ -0,0 +1,96 @@
.analytics-panel {
&__body {
padding: 12px 6px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
// TabsRoot — last direct child div
> div:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
&__list {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 4px 8px;
padding: 8px 0;
align-items: center;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
&__service-name {
font-size: 13px;
color: var(--l1-foreground);
word-break: break-word;
}
&__bar-cell {
display: flex;
align-items: center;
gap: 8px;
}
&__bar {
flex: 1;
height: 6px;
background: var(--l3-background);
border-radius: 3px;
min-width: 40px;
&--small {
max-width: 80px;
flex: 0 0 80px;
}
}
&__bar-fill {
height: 100%;
border-radius: 3px;
}
&__value {
flex-shrink: 0;
text-align: right;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&--wide {
min-width: 55px;
}
&--narrow {
min-width: 25px;
}
}
// Tabs root
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}

View File

@@ -5,7 +5,6 @@ import {
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import cx from 'classnames';
import { DetailsHeader } from 'components/DetailsPanel';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
@@ -17,7 +16,7 @@ import {
getAggregationMap as findAggregationMap,
} from '../../utils/aggregations';
import styles from './AnalyticsPanel.module.scss';
import './AnalyticsPanel.styles.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
@@ -87,6 +86,7 @@ function AnalyticsPanel({
return (
<FloatingPanel
isOpen
className="analytics-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
@@ -110,7 +110,7 @@ function AnalyticsPanel({
className="floating-panel__drag-handle"
/>
<div className={styles.body}>
<div className="analytics-panel__body">
<TabsRoot defaultValue="exec-time">
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">
@@ -121,30 +121,33 @@ function AnalyticsPanel({
</TabsTrigger>
</TabsList>
<div className={styles.tabsScroll}>
<div className="analytics-panel__tabs-scroll">
<TabsContent value="exec-time">
<div className={styles.list}>
<div className="analytics-panel__list">
{execTimeRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
<span
key={`${row.group}-name`}
className="analytics-panel__service-name"
>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className={styles.barFill}
className="analytics-panel__bar-fill"
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueWide)}>
<span className="analytics-panel__value analytics-panel__value--wide">
{row.percentage.toFixed(2)}%
</span>
</div>
@@ -154,28 +157,31 @@ function AnalyticsPanel({
</TabsContent>
<TabsContent value="spans">
<div className={styles.list}>
<div className="analytics-panel__list">
{spanCountRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
<span
key={`${row.group}-name`}
className="analytics-panel__service-name"
>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className={styles.barFill}
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / row.max) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueNarrow)}>
<span className="analytics-panel__value analytics-panel__value--narrow">
{row.count}
</span>
</div>

View File

@@ -1,26 +0,0 @@
.root {
position: relative;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
.label {
white-space: nowrap;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}

View File

@@ -0,0 +1,34 @@
.linked-spans {
position: relative;
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
&__label {
white-space: nowrap;
}
&__chevron {
transition: transform 0.15s ease;
&--open {
transform: rotate(90deg);
}
}
&__list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
}

View File

@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
import ROUTES from 'constants/routes';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import styles from './LinkedSpans.module.scss';
import './LinkedSpans.styles.scss';
interface SpanReference {
traceId: string;
@@ -56,12 +56,12 @@ export function LinkedSpansToggle({
toggleOpen: () => void;
}): JSX.Element {
if (count === 0) {
return <span className={styles.label}>0 linked spans</span>;
return <span className="linked-spans__label">0 linked spans</span>;
}
return (
<button type="button" className={styles.toggle} onClick={toggleOpen}>
<span className={styles.label}>
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
<span className="linked-spans__label">
{count} linked span{count !== 1 ? 's' : ''}
</span>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
@@ -87,7 +87,7 @@ export function LinkedSpansPanel({
}
return (
<div className={styles.list}>
<div className="linked-spans__list">
{linkedSpans.map((item) => (
<KeyValueLabel
key={item.spanId}
@@ -108,7 +108,7 @@ function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
return (
<div className={styles.root}>
<div className="linked-spans">
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
</div>

View File

@@ -1,146 +0,0 @@
.root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.body {
padding: 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
gap: 16px;
// DataViewer keeps its global `.data-viewer` class — give it a min-height
// so the tab area doesn't collapse on short content.
:global(.data-viewer) {
min-height: 500px;
}
}
.detailsSection {
flex-shrink: 0;
min-width: 0;
}
.tabsSection {
flex: 1;
min-width: 0;
max-height: 100%;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
// TabsRoot — direct child of tabs-section
> div {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}
.tabsScroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
[role='tabpanel'] {
padding: 0;
flex: 1;
min-height: 0;
}
}
.spanRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
.spanInfo {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
.spanInfoItem {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
.highlightedOptions {
padding: 8px 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
// KeyValueLabel uses a global `.key-value-label` root; constrain it
// inside the two-column grid so values can ellipsize cleanly.
:global(.key-value-label) {
width: auto;
min-width: 0;
overflow: hidden;
}
}
.serviceDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
}
.traceId {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.traceIdCopy {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
height: auto;
justify-content: flex-start;
}
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
// tooltip (z-index 50). Bump the tooltip above the panel.
.dockToggleTooltip {
--tooltip-z-index: 1000;
}

View File

@@ -0,0 +1,170 @@
.span-details-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&__header-nav {
display: flex;
align-items: center;
gap: 2px;
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
gap: 16px;
.data-viewer {
min-height: 500px;
}
}
&__details-section {
flex-shrink: 0;
min-width: 0;
}
&__tabs-section {
flex: 1;
min-width: 0;
max-height: 100%;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
// TabsRoot — direct child of tabs-section
> div {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
[role='tabpanel'] {
padding: 0;
flex: 1;
min-height: 0;
}
}
&__span-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
&__span-info {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
&__span-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
&__highlighted-options {
padding: 8px 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
.key-value-label {
width: auto;
min-width: 0;
overflow: hidden;
}
}
&__service-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
}
&__trace-id {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__trace-id-copy {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
height: auto;
justify-content: flex-start;
}
&__key-attributes {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
&-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: var(--line-height-20);
}
&-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
// tooltip (z-index 50). Bump the tooltip above the panel.
.dock-toggle-tooltip {
--tooltip-z-index: 1000;
}

View File

@@ -7,7 +7,7 @@ import {
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
TooltipRoot,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
@@ -72,7 +72,7 @@ import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import styles from './SpanDetailsPanel.module.scss';
import './SpanDetailsPanel.styles.scss';
interface SpanDetailsPanelProps {
panelState: DetailsPanelState;
@@ -275,9 +275,9 @@ function SpanDetailsContent({
// }, [selectedSpan]);
return (
<div className={styles.body}>
<div className={styles.detailsSection}>
<div className={styles.spanRow}>
<div className="span-details-panel__body">
<div className="span-details-panel__details-section">
<div className="span-details-panel__span-row">
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
@@ -296,8 +296,8 @@ function SpanDetailsContent({
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
{/* Span info: exec time + start time */}
<div className={styles.spanInfo}>
<div className={styles.spanInfoItem}>
<div className="span-details-panel__span-info">
<div className="span-details-panel__span-info-item">
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
@@ -316,13 +316,13 @@ function SpanDetailsContent({
)}
</span>
</div>
<div className={styles.spanInfoItem}>
<div className="span-details-panel__span-info-item">
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className={styles.spanInfoItem}>
<div className="span-details-panel__span-info-item">
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
@@ -338,7 +338,7 @@ function SpanDetailsContent({
/>
{/* Step 6: HighlightedOptions */}
<div className={styles.highlightedOptions}>
<div className="span-details-panel__highlighted-options">
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
@@ -382,7 +382,7 @@ function SpanDetailsContent({
{/* Step 8: MiniTraceContext */}
</div>
<div className={styles.tabsSection}>
<div className="span-details-panel__tabs-section">
{/* Step 9: ContentTabs */}
<TabsRoot defaultValue="overview">
<TabsList variant="secondary">
@@ -402,7 +402,7 @@ function SpanDetailsContent({
)}
</TabsList>
<div className={styles.tabsScroll}>
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={spanDisplayData}
@@ -497,7 +497,7 @@ function SpanDetailsPanel({
key: 'dock-toggle',
component: (
<TooltipProvider>
<TooltipRoot>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
@@ -512,10 +512,10 @@ function SpanDetailsPanel({
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className={styles.dockToggleTooltip}>
<TooltipContent className="dock-toggle-tooltip">
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</TooltipRoot>
</Tooltip>
</TooltipProvider>
),
});
@@ -546,7 +546,7 @@ function SpanDetailsPanel({
traceEndTime={traceEndTime}
/>
) : (
<div className={styles.body}>
<div className="span-details-panel__body">
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
</div>
)}
@@ -554,7 +554,7 @@ function SpanDetailsPanel({
);
if (variant === SpanDetailVariant.DOCKED) {
return <div className={styles.root}>{content}</div>;
return <div className="span-details-panel">{content}</div>;
}
if (variant === SpanDetailVariant.DRAWER) {
@@ -562,7 +562,7 @@ function SpanDetailsPanel({
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className={styles.root}
className="span-details-panel"
>
{content}
</DetailsPanelDrawer>
@@ -572,7 +572,7 @@ function SpanDetailsPanel({
return (
<FloatingPanel
isOpen={panelState.isOpen}
className={styles.root}
className="span-details-panel"
width={PANEL_WIDTH}
minWidth={480}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}

View File

@@ -0,0 +1,258 @@
// Badge — wraps a KeyValueLabel, clickable to toggle panel
.span-percentile-badge {
cursor: pointer;
// Override key color for the percentile value (p99)
.key-value-label__key {
color: var(--destructive);
}
&__loader {
display: inline-flex;
align-items: center;
padding: 2px 4px;
color: var(--foreground);
}
&__value {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__icon {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
// Panel — collapsible, renders below the row
.span-percentile-panel {
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
&-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
&-icon {
color: var(--l2-foreground);
}
}
&__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
&-title {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
}
&-highlight {
color: var(--destructive);
}
&-loader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
}
&__timerange {
width: 100%;
&-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
}
&__table {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
&-text {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
}
&-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
&-skeleton {
.ant-skeleton-title {
width: 100% !important;
margin-top: 0 !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
&-key {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&-value {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
&-dash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
&--current {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.span-percentile-panel__table-row-key {
color: var(--text-robin-300);
}
.span-percentile-panel__table-row-dash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-panel__table-row-value {
color: var(--text-robin-400);
}
}
}
}
&__resource-selector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
&-header {
border-bottom: 1px solid var(--l1-border);
}
&-input {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
&-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
&-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
&-value {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}
}
}
}

View File

@@ -1,28 +0,0 @@
// Badge — wraps a KeyValueLabel, clickable to toggle panel
.root {
cursor: pointer;
// KeyValueLabel renders its key with a global class; recolor only the badge
// instance inside this badge wrapper.
:global(.key-value-label__key) {
color: var(--destructive);
}
}
.loader {
display: inline-flex;
align-items: center;
padding: 2px 4px;
color: var(--foreground);
}
.value {
display: inline-flex;
align-items: center;
gap: 4px;
}
.icon {
flex-shrink: 0;
color: var(--l2-foreground);
}

View File

@@ -3,7 +3,7 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import styles from './SpanPercentileBadge.module.scss';
import './SpanPercentile.styles.scss';
type SpanPercentileBadgeProps = Pick<
UseSpanPercentileReturn,
@@ -25,7 +25,7 @@ function SpanPercentileBadge({
}: SpanPercentileBadgeProps): JSX.Element | null {
if (loading) {
return (
<div className={styles.loader}>
<div className="span-percentile-badge__loader">
<Loader size={14} className="animate-spin" />
</div>
);
@@ -37,7 +37,7 @@ function SpanPercentileBadge({
return (
<div
className={styles.root}
className="span-percentile-badge"
onClick={toggleOpen}
role="button"
tabIndex={0}
@@ -50,12 +50,12 @@ function SpanPercentileBadge({
<KeyValueLabel
badgeKey={`p${percentileValue}`}
badgeValue={
<span className={styles.value}>
<span className="span-percentile-badge__value">
{duration}
{isOpen ? (
<ChevronUp size={14} className={styles.icon} />
<ChevronUp size={14} className="span-percentile-badge__icon" />
) : (
<ChevronDown size={14} className={styles.icon} />
<ChevronDown size={14} className="span-percentile-badge__icon" />
)}
</span>
}

View File

@@ -1,217 +0,0 @@
// Panel — collapsible, renders below the row
.root {
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--l1-border);
}
.content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.contentTitle {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
}
.contentHighlight {
color: var(--destructive);
}
.contentLoader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
.timerange {
width: 100%;
}
.timerangeSelect {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
:global(.ant-select-selector) {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tableHeaderText {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
.tableRows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tableSkeleton {
:global(.ant-skeleton-title) {
width: 100% !important;
margin-top: 0 !important;
}
:global(.ant-skeleton-paragraph) {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
.tableRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.tableRowKey {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
.tableRowValue {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
.tableRowDash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.isCurrent {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.tableRowKey {
color: var(--text-robin-300);
}
.tableRowDash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.tableRowValue {
color: var(--text-robin-400);
}
}
.resourceSelector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.resourceSelectorHeader {
border-bottom: 1px solid var(--l1-border);
}
.resourceSelectorInput {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
.resourceSelectorItems {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
.resourceSelectorItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.resourceSelectorItemValue {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}

View File

@@ -1,7 +1,5 @@
import { Checkbox, Input, Select, Skeleton } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
@@ -10,7 +8,7 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import styles from './SpanPercentilePanel.module.scss';
import './SpanPercentile.styles.scss';
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
@@ -55,46 +53,46 @@ function SpanPercentilePanel({
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Button
variant="link"
color="secondary"
<div className="span-percentile-panel">
<div className="span-percentile-panel__header">
<Typography.Text
className="span-percentile-panel__header-text"
onClick={toggleOpen}
prefix={<ChevronDown size={16} />}
>
Span Percentile
</Button>
<ChevronDown size={16} /> Span Percentile
</Typography.Text>
<Button
variant="link"
color="secondary"
size="icon"
onClick={(): void =>
setShowResourceAttributesSelector(!showResourceAttributesSelector)
}
prefix={
showResourceAttributesSelector ? <Check size={16} /> : <Plus size={16} />
}
/>
{showResourceAttributesSelector ? (
<Check
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(false)}
/>
) : (
<Plus
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(true)}
/>
)}
</div>
{showResourceAttributesSelector && (
<div
className={styles.resourceSelector}
className="span-percentile-panel__resource-selector"
ref={resourceAttributesSelectorRef}
>
<div className={styles.resourceSelectorHeader}>
<div className="span-percentile-panel__resource-selector-header">
<Input
placeholder="Search resource attributes"
className={styles.resourceSelectorInput}
className="span-percentile-panel__resource-selector-input"
value={resourceAttributesSearchQuery}
onChange={(e): void =>
setResourceAttributesSearchQuery(e.target.value as string)
}
/>
</div>
<div className={styles.resourceSelectorItems}>
<div className="span-percentile-panel__resource-selector-items">
{spanResourceAttributes
.filter((attr) =>
attr.key
@@ -102,7 +100,10 @@ function SpanPercentilePanel({
.includes(resourceAttributesSearchQuery.toLowerCase()),
)
.map((attr) => (
<div className={styles.resourceSelectorItem} key={attr.key}>
<div
className="span-percentile-panel__resource-selector-item"
key={attr.key}
>
<Checkbox
checked={attr.isSelected}
onChange={(e): void => {
@@ -117,7 +118,9 @@ function SpanPercentilePanel({
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
}
>
<div className={styles.resourceSelectorItemValue}>{attr.key}</div>
<div className="span-percentile-panel__resource-selector-item-value">
{attr.key}
</div>
</Checkbox>
</div>
))}
@@ -125,15 +128,15 @@ function SpanPercentilePanel({
</div>
)}
<div className={styles.content}>
<Typography.Text className={styles.contentTitle}>
<div className="span-percentile-panel__content">
<Typography.Text className="span-percentile-panel__content-title">
This span duration is{' '}
{!loading && spanPercentileData ? (
<span className={styles.contentHighlight}>
<span className="span-percentile-panel__content-highlight">
p{Math.floor(spanPercentileData.percentile || 0)}
</span>
) : (
<span className={styles.contentLoader}>
<span className="span-percentile-panel__content-loader">
<Loader size={12} className="animate-spin" />
</span>
)}{' '}
@@ -141,11 +144,11 @@ function SpanPercentilePanel({
hour(s) since the span start time.
</Typography.Text>
<div className={styles.timerange}>
<div className="span-percentile-panel__timerange">
<Select
labelInValue
placeholder="Select timerange"
className={styles.timerangeSelect}
className="span-percentile-panel__timerange-select"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
@@ -164,45 +167,45 @@ function SpanPercentilePanel({
/>
</div>
<div>
<div className={styles.tableHeader}>
<Typography.Text className={styles.tableHeaderText}>
<div className="span-percentile-panel__table">
<div className="span-percentile-panel__table-header">
<Typography.Text className="span-percentile-panel__table-header-text">
Percentile
</Typography.Text>
<Typography.Text className={styles.tableHeaderText}>
<Typography.Text className="span-percentile-panel__table-header-text">
Duration
</Typography.Text>
</div>
<div className={styles.tableRows}>
<div className="span-percentile-panel__table-rows">
{isLoadingData || isFetchingData ? (
<Skeleton
active
paragraph={{ rows: 3 }}
className={styles.tableSkeleton}
className="span-percentile-panel__table-skeleton"
/>
) : (
<>
{Object.entries(spanPercentileData?.percentiles || {}).map(
([pKey, pDuration]) => (
<div className={styles.tableRow} key={pKey}>
<Typography.Text className={styles.tableRowKey}>
<div className="span-percentile-panel__table-row" key={pKey}>
<Typography.Text className="span-percentile-panel__table-row-key">
{pKey}
</Typography.Text>
<div className={styles.tableRowDash} />
<Typography.Text className={styles.tableRowValue}>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
{getYAxisFormattedValue(`${pDuration / 1000000}`, 'ms')}
</Typography.Text>
</div>
),
)}
<div className={cx(styles.tableRow, styles.isCurrent)}>
<Typography.Text className={styles.tableRowKey}>
<div className="span-percentile-panel__table-row span-percentile-panel__table-row--current">
<Typography.Text className="span-percentile-panel__table-row-key">
p{Math.floor(spanPercentileData?.percentile || 0)}
</Typography.Text>
<div className={styles.tableRowDash} />
<Typography.Text className={styles.tableRowValue}>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.duration_nano / 1000000}`,

View File

@@ -5,8 +5,6 @@ import { toast } from '@signozhq/ui/sonner';
import ROUTES from 'constants/routes';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './SpanDetailsPanel.module.scss';
interface TraceIdFieldProps {
span: SpanV3;
}
@@ -38,7 +36,7 @@ export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
<Button
variant="link"
color="secondary"
className={styles.traceIdCopy}
className="span-details-panel__trace-id-copy"
onClick={handleCopy}
title="Click to copy trace ID"
>
@@ -53,7 +51,7 @@ export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className={styles.traceId}
className="span-details-panel__trace-id"
>
{span.trace_id}
</Link>

View File

@@ -2,7 +2,6 @@ import { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './SpanDetailsPanel.module.scss';
import { TraceIdField } from './TraceIdField';
interface HighlightedOption {
@@ -18,7 +17,7 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
render: (span): ReactNode | null =>
span['service.name'] ? (
<Badge color="vanilla">
<span className={styles.serviceDot} />
<span className="span-details-panel__service-dot" />
{span['service.name']}
</Badge>
) : null,

View File

@@ -1,60 +0,0 @@
.root {
font-size: 12px;
color: var(--l1-foreground);
max-width: 300px;
}
.header {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {
font-weight: 600;
margin-bottom: 2px;
color: var(--text-robin-400);
}
.hasError {
color: var(--destructive);
}
.time {
font-size: 11px;
color: var(--l2-foreground);
margin-bottom: 4px;
}
.divider {
border-top: 1px solid var(--l2-border);
margin: 6px 0;
}
.attributes {
font-size: 11px;
}
.kv {
margin-bottom: 2px;
line-height: 1.4;
word-break: break-all;
}
.key {
color: var(--l2-foreground);
}
.value {
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,60 @@
.event-tooltip-content {
font-size: 12px;
color: var(--l1-foreground);
max-width: 300px;
&__header {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
&__name {
font-weight: 600;
margin-bottom: 2px;
color: var(--text-robin-400);
&.error {
color: var(--destructive);
}
}
&__time {
font-size: 11px;
color: var(--l2-foreground);
margin-bottom: 4px;
}
&__divider {
border-top: 1px solid var(--l2-border);
margin: 6px 0;
}
&__attributes {
font-size: 11px;
}
&__kv {
margin-bottom: 2px;
line-height: 1.4;
word-break: break-all;
}
&__key {
color: var(--l2-foreground);
}
&__value {
color: var(--l1-foreground);
}
}

View File

@@ -1,9 +1,8 @@
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { Diamond } from '@signozhq/icons';
import cx from 'classnames';
import { toFixed } from 'utils/toFixed';
import styles from './EventTooltipContent.module.scss';
import './EventTooltipContent.styles.scss';
export interface EventTooltipContentProps {
eventName: string;
@@ -21,25 +20,25 @@ export function EventTooltipContent({
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
return (
<div className={styles.root}>
<div className={styles.header}>
<div className="event-tooltip-content">
<div className="event-tooltip-content__header">
<Diamond size={10} />
<span>EVENT DETAILS</span>
</div>
<div className={cx(styles.name, isError && styles.hasError)}>
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
{eventName}
</div>
<div className={styles.time}>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} since span start
</div>
{Object.keys(attributeMap).length > 0 && (
<>
<div className={styles.divider} />
<div className={styles.attributes}>
<div className="event-tooltip-content__divider" />
<div className="event-tooltip-content__attributes">
{Object.entries(attributeMap).map(([key, value]) => (
<div key={key} className={styles.kv}>
<span className={styles.key}>{key}:</span>{' '}
<span className={styles.value}>{value}</span>
<div key={key} className="event-tooltip-content__kv">
<span className="event-tooltip-content__key">{key}:</span>{' '}
<span className="event-tooltip-content__value">{value}</span>
</div>
))}
</div>

View File

@@ -2,13 +2,13 @@
// `top` is updated by the parent to track the hovered row's Y; its `left`
// is the sidebar/timeline boundary so the popover always opens at the same
// X regardless of which row is hovered.
.anchor {
.span-hover-card-anchor {
position: absolute;
width: 1px;
pointer-events: none;
}
.popover {
.span-hover-card-popover {
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
@@ -20,29 +20,31 @@
color: var(--l1-foreground);
}
.content {
// Flamegraph tooltip rendered as a portal, uses same semantic tokens.
// Position is set inline on the element (left/top track the cursor); the
// static layout/decoration lives here.
.flamegraph-tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
}
.span-hover-card-content {
font-size: 12px;
color: var(--l1-foreground);
}
.name {
font-weight: 600;
margin-bottom: 4px;
}
&__name {
font-weight: 600;
margin-bottom: 4px;
}
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
&__row {
line-height: 1.5;
color: var(--l2-foreground);
}
}

View File

@@ -1,5 +1,5 @@
import {
TooltipRoot,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
@@ -11,7 +11,7 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import styles from './SpanHoverCard.module.scss';
import './SpanHoverCard.styles.scss';
/**
* Span-level fields that the tooltip always shows (as the colored title or
@@ -51,21 +51,27 @@ export function SpanTooltipContent({
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.name} style={{ color }}>
<div className="span-hover-card-content">
<div className="span-hover-card-content__name" style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
<div className="span-hover-card-content__row">
status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<div className="span-hover-card-content__preview">
{previewRows.map((row) => (
<div key={row.key} className={styles.row}>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
<div key={row.key} className="span-hover-card-content__row">
<span className="span-hover-card-content__preview-key">{row.key}:</span>{' '}
<span className="span-hover-card-content__preview-value">
{row.value}
</span>
</div>
))}
</div>
@@ -143,10 +149,10 @@ export function SpanHoverCard({
return (
<TooltipProvider>
<TooltipRoot open={hoverCardData !== null} onOpenChange={onOpenChange}>
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
<TooltipTrigger asChild>
<div
className={styles.anchor}
className="span-hover-card-anchor"
style={{
top: hoverCardData?.anchorTop ?? 0,
left: anchorLeft,
@@ -158,11 +164,11 @@ export function SpanHoverCard({
side="right"
align="start"
sideOffset={8}
className={styles.popover}
className="span-hover-card-popover"
>
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
</TooltipContent>
</TooltipRoot>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -1,75 +0,0 @@
.wrapper {
flex-shrink: 0;
position: relative;
}
.header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 8px;
// KeyValueLabel renders with a global `.key-value-label` root; keep it from
// shrinking on the trace details header.
:global(.key-value-label) {
flex-shrink: 0;
}
}
.backBtn {
flex-shrink: 0;
}
.filter {
min-width: 0;
}
.isExpanded {
max-width: none;
flex: 1;
}
.oldViewBtn {
flex-shrink: 0;
}
.analyticsBtn {
flex-shrink: 0;
margin-left: auto;
}
.subHeader {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 16px 8px;
font-size: 13px;
color: var(--l2-foreground);
}
.subItem {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.separator {
color: var(--l2-foreground);
opacity: 0.5;
}
.entryPointBadge {
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
font-size: 12px;
}
.skeleton {
:global(.ant-skeleton-input) {
width: 160px !important;
min-height: 20px !important;
height: 20px !important;
}
}

View File

@@ -0,0 +1,74 @@
.trace-details-header-wrapper {
flex-shrink: 0;
position: relative;
}
.trace-details-header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 8px;
&__back-btn {
flex-shrink: 0;
}
.key-value-label {
flex-shrink: 0;
}
&__filter {
&.trace-v3-filter-row {
padding: 0;
}
min-width: 0;
&--expanded {
max-width: none;
flex: 1;
}
}
&__old-view-btn {
flex-shrink: 0;
}
&__analytics-btn {
flex-shrink: 0;
margin-left: auto;
}
&__sub-header {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 16px 8px;
font-size: 13px;
color: var(--l2-foreground);
}
&__sub-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
&__separator {
color: var(--l2-foreground);
opacity: 0.5;
}
&__entry-point-badge {
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
font-size: 12px;
}
&__skeleton .ant-skeleton-input {
width: 160px !important;
min-height: 20px !important;
height: 20px !important;
}
}

View File

@@ -2,14 +2,13 @@ import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
TooltipRoot,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Skeleton } from 'antd';
import setLocalStorageKey from 'api/browser/localstorage/set';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
@@ -34,7 +33,7 @@ import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import './TraceDetailsHeader.styles.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
@@ -69,7 +68,7 @@ function DetailsLoader(): JSX.Element {
key={i}
active
size="small"
className={styles.skeleton}
className="trace-details-header__skeleton"
/>
))}
</>
@@ -121,15 +120,15 @@ function TraceDetailsHeader({
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<div className="trace-details-header-wrapper">
<div className="trace-details-header">
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="md"
className={styles.backBtn}
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
@@ -146,20 +145,20 @@ function TraceDetailsHeader({
{!isFilterExpanded && (
<>
<TooltipProvider>
<TooltipRoot>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.analyticsBtn}
className="trace-details-header__analytics-btn"
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
<ChartPie size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Analytics</TooltipContent>
</TooltipRoot>
</Tooltip>
</TooltipProvider>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
@@ -168,7 +167,11 @@ function TraceDetailsHeader({
/>
</>
)}
<div className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}>
<div
className={`trace-details-header__filter${
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
}`}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
@@ -184,7 +187,7 @@ function TraceDetailsHeader({
variant="solid"
color="secondary"
size="sm"
className={styles.oldViewBtn}
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Legacy View
@@ -195,22 +198,22 @@ function TraceDetailsHeader({
</div>
{showTraceDetails && (
<div className={styles.subHeader}>
<div className="trace-details-header__sub-header">
{traceMetadata ? (
<>
<span className={styles.subItem}>
<span className="trace-details-header__sub-item">
<Server size={13} />
{traceMetadata.rootServiceName}
<span className={styles.separator}></span>
<span className={styles.entryPointBadge}>
<span className="trace-details-header__separator"></span>
<span className="trace-details-header__entry-point-badge">
{traceMetadata.rootServiceEntryPoint}
</span>
</span>
<span className={styles.subItem}>
<span className="trace-details-header__sub-item">
<Timer size={13} />
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
</span>
<span className={styles.subItem}>
<span className="trace-details-header__sub-item">
<CalendarClock size={13} />
{dayjs(traceMetadata.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,

View File

@@ -1,127 +0,0 @@
.root {
height: calc(100vh);
display: flex;
flex-direction: column;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
// collapse panels.
.flameCollapse,
.waterfallCollapse {
border: none;
border-radius: 0;
background: transparent;
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
}
:global(.ant-collapse-content) {
background: transparent;
border-top: none;
// Disable collapse animation — virtualizer and canvas flicker during
// height transitions.
transition: none !important;
}
}
.collapseLabel {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.collapseTitle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.collapseCount {
display: flex;
gap: 12px;
align-items: center;
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
.collapseCountItem {
display: inline-flex;
align-items: center;
gap: 4px;
}
.hasErrors {
color: var(--destructive);
}
.flameCollapse {
flex-shrink: 0;
:global(.ant-collapse-content-box) {
padding: 0 !important;
}
}
.waterfallCollapse {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
:global(.ant-collapse-item) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.ant-collapse-content.ant-collapse-content-active) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
&.isDocked {
flex: none;
:global(.ant-collapse-item) {
flex: none;
display: block;
}
:global(.ant-collapse-content-box) {
flex: none;
display: block;
}
}
}
.dockedSpanDetails {
flex: 1;
overflow: auto;
min-height: 0;
}

View File

@@ -0,0 +1,124 @@
.trace-details-v3 {
height: calc(100vh);
display: flex;
flex-direction: column;
&__content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
&__flame-collapse,
&__waterfall-collapse {
border: none;
border-radius: 0;
background: transparent;
.ant-collapse-item {
border: none;
}
.ant-collapse-header {
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
}
.ant-collapse-content {
background: transparent;
border-top: none;
// Disable collapse animation — virtualizer and canvas flicker during height transitions
transition: none !important;
}
}
&__collapse-label {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
&__collapse-title {
display: inline-flex;
align-items: center;
gap: 6px;
}
&__collapse-count {
display: flex;
gap: 12px;
align-items: center;
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
&__collapse-count-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__collapse-count-errors {
color: var(--destructive);
}
&__flame-collapse {
flex-shrink: 0;
.ant-collapse-content-box {
padding: 0 !important;
}
}
&__waterfall-collapse {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-collapse-item {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-collapse-content.ant-collapse-content-active {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-collapse-content-box {
padding: 0 !important;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
&--docked {
flex: none;
.ant-collapse-item {
flex: none;
display: block;
}
.ant-collapse-content-box {
flex: none;
display: block;
}
}
}
&__docked-span-details {
flex: 1;
overflow: auto;
min-height: 0;
}
}

View File

@@ -1,42 +0,0 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 15px;
}
.viewport {
flex: 1;
overflow: hidden;
position: relative;
}
.main {
display: block;
width: 100%;
cursor: grab;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
}
// Flamegraph tooltip — rendered as a portal, follows the cursor.
// Position is set inline on the element (left/top); the layout/decoration
// lives here. Shares visual treatment with the waterfall hover popover
// but is positioned `fixed` instead of anchored.
.tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,26 @@
.flamegraph-canvas {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 15px;
}
.flamegraph-canvas__viewport {
flex: 1;
overflow: hidden;
position: relative;
}
.flamegraph-canvas__main {
display: block;
width: 100%;
cursor: grab;
}
.flamegraph-canvas__overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
}

View File

@@ -16,7 +16,7 @@ import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
import styles from './FlamegraphCanvas.module.scss';
import './FlamegraphCanvas.styles.scss';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const {
@@ -194,7 +194,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const tooltipElement = tooltipContent
? createPortal(
<div
className={styles.tooltip}
className="span-hover-card-popover flamegraph-tooltip"
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
@@ -223,7 +223,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
: null;
return (
<div className={styles.root}>
<div className="flamegraph-canvas">
{tooltipElement}
<TimelineV3
startTimestamp={viewStartTs}
@@ -234,7 +234,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
/>
<div
ref={containerRef}
className={styles.viewport}
className="flamegraph-canvas__viewport"
onMouseEnter={(): void => {
isOverFlamegraphRef.current = true;
}}
@@ -242,7 +242,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
>
<canvas
ref={canvasRef}
className={styles.main}
className="flamegraph-canvas__main"
onMouseDown={(e): void => {
handleMouseDown(e);
handleMouseDownForClick(e);
@@ -251,7 +251,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
onMouseUp={handleMouseUp}
onClick={handleClick}
/>
<canvas ref={overlayCanvasRef} className={styles.overlay} />
<canvas ref={overlayCanvasRef} className="flamegraph-canvas__overlay" />
</div>
</div>
);

View File

@@ -1,230 +0,0 @@
// Container — applied to the `.ant-modal` element via SignozModal's className
// prop. Ant Modal portals into document.body, but the className still lives on
// the modal root, so descendant overrides work via `:global` nesting.
.container {
:global(.ant-modal-content),
:global(.ant-modal-header) {
background: var(--l1-background);
}
:global(.ant-modal-header) {
border-bottom: none;
:global(.ant-modal-title) {
color: var(--l1-foreground);
}
}
:global(.ant-modal-body) {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
:global(.ant-modal-footer) {
margin-top: 0;
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
padding: 16px !important;
.saveButton {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
:global(.ant-btn-icon) {
display: flex;
}
&:disabled {
color: var(--l2-foreground);
:global(.ant-btn-icon svg) {
stroke: var(--l2-foreground);
}
}
}
.discardButton {
background: var(--l3-background);
}
:global(.ant-btn) {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
// Inner content wrapper (sibling of modal chrome)
.root {
// Funnel-detail view overrides — only when the inner wrapper has
// `.isDetails` applied alongside `.root`.
&.isDetails {
:global(.traces-funnel-details) {
height: unset;
:global(.traces-funnel-details__steps-config) {
width: unset;
border: none;
}
:global(.funnel-step-wrapper) {
gap: 15px;
}
:global(.steps-content) {
max-height: 500px;
}
}
}
:global(.funnel-item) {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
:global(.funnel-item__header) {
line-height: 20px;
}
:global(.funnel-item__details) {
line-height: 18px;
}
}
}
.loadingSpinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
.search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
}
.searchInput {
flex: 1;
padding: 6px 8px;
background: var(--l3-background);
:global(.ant-input-prefix) {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--l3-background);
}
input::placeholder {
color: var(--l2-foreground);
opacity: 0.6;
}
}
.createButton {
width: 153px;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l3-background);
border: none;
box-shadow: none;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.list {
max-height: 400px;
overflow-y: scroll;
:global(.funnels-empty__content) {
padding: 0;
}
:global(.funnels-list) {
gap: 8px;
:global(.funnel-item) {
padding: 8px 16px 12px;
:global(.funnel-item__details) {
margin-top: 8px;
}
}
}
}
.spinner {
height: 400px;
}
.backButton {
display: flex;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
.detailsView {
display: flex;
flex-direction: column;
gap: 24px;
:global(.funnel-configuration__steps) {
padding: 0;
:global(.funnel-step__content .filters__service-and-span .ant-select) {
width: 170px;
}
:global(.funnel-step__footer .error) {
width: 25%;
}
:global(.inter-step-config) {
width: calc(100% - 104px);
}
}
:global(.funnel-item__actions-popover) {
display: none;
}
}

View File

@@ -0,0 +1,236 @@
// Modal base styles
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--l1-background);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--l1-foreground);
}
}
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
.ant-btn-icon {
display: flex;
}
&:disabled {
color: var(--l2-foreground);
.ant-btn-icon {
svg {
stroke: var(--l2-foreground);
}
}
}
}
&__discard-button {
background: var(--l3-background);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
max-height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--l3-background);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--l3-background);
}
input::placeholder {
color: var(--l2-foreground);
opacity: 0.6;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l3-background);
border: none;
box-shadow: none;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}

View File

@@ -22,7 +22,7 @@ import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { FunnelData } from 'types/api/traceFunnels';
import styles from './AddSpanToFunnelModal.module.scss';
import './AddSpanToFunnelModal.styles.scss';
enum ModalView {
LIST = 'list',
@@ -62,7 +62,7 @@ function FunnelDetailsView({
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
return (
<div className={styles.detailsView}>
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
@@ -161,11 +161,11 @@ function AddSpanToFunnelModal({
};
const renderListView = (): JSX.Element => (
<div className={styles.root}>
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className={styles.search}>
<div className="add-span-to-funnel-modal__search">
<Input
className={styles.searchInput}
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
@@ -173,7 +173,7 @@ function AddSpanToFunnelModal({
/>
</div>
)}
<div className={styles.list}>
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
@@ -201,11 +201,11 @@ function AddSpanToFunnelModal({
);
const renderDetailsView = ({ span }: { span: SpanV3 }): JSX.Element => (
<div className={cx(styles.root, styles.isDetails)}>
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
variant="ghost"
color="secondary"
className={styles.backButton}
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
prefix={<ArrowLeft size={14} />}
>
@@ -214,7 +214,7 @@ function AddSpanToFunnelModal({
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className={styles.loadingSpinner}
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoaderCircle size={14} className="animate-spin" />}
>
@@ -245,7 +245,10 @@ function AddSpanToFunnelModal({
onCancel={onClose}
width={570}
title="Add span to funnel"
className={styles.container}
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
footer={
activeView === ModalView.DETAILS
? [
@@ -254,7 +257,7 @@ function AddSpanToFunnelModal({
color="secondary"
key="discard"
onClick={handleDiscard}
className={styles.discardButton}
className="add-span-to-funnel-modal__discard-button"
disabled={!isUnsavedChanges}
>
Discard
@@ -263,7 +266,7 @@ function AddSpanToFunnelModal({
key="save"
variant="solid"
color="primary"
className={styles.saveButton}
className="add-span-to-funnel-modal__save-button"
onClick={handleSaveFunnel}
disabled={!isUnsavedChanges}
prefix={<Check size={14} />}
@@ -276,7 +279,7 @@ function AddSpanToFunnelModal({
key="create"
variant="outlined"
color="secondary"
className={styles.createButton}
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
prefix={<Plus size={14} />}
>

View File

@@ -1,4 +1,4 @@
.root {
.span-line-action-buttons {
display: flex;
position: absolute;
transform: translate(-50%, -50%);
@@ -8,20 +8,20 @@
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
}
.copyBtn {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
border-color: var(--l1-border) !important;
.copy-span-btn {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
border-color: var(--l1-border) !important;
}
}
// Tooltip rendered in a portal; bump above FloatingPanel (z-index 999) so it
// stays visible when the SpanDetailsPanel is docked as a floating panel.
.tooltip {
.span-line-action-tooltip {
--tooltip-z-index: 1000;
}

View File

@@ -70,6 +70,24 @@ describe('SpanLineActionButtons', () => {
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
});
it('applies correct styling classes', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the main container has the correct class
const container = screen
.getByRole('button')
.closest('.span-line-action-buttons');
expect(container).toHaveClass('span-line-action-buttons');
// Check if the button has the correct class
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveClass('copy-span-btn');
});
it('copies span link to clipboard when copy button is clicked', () => {
const mockSetCopy = jest.fn();
const mockUrlQuery = {

View File

@@ -1,6 +1,6 @@
import { Button } from '@signozhq/ui/button';
import {
TooltipRoot,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
@@ -9,7 +9,7 @@ import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Link } from '@signozhq/icons';
import { Span } from 'types/api/trace/getTraceV2';
import styles from './SpanLineActionButtons.module.scss';
import './SpanLineActionButtons.styles.scss';
export interface SpanLineActionButtonsProps {
span: Span;
@@ -20,22 +20,24 @@ export default function SpanLineActionButtons({
const { onSpanCopy } = useCopySpanLink(span);
return (
<div className={styles.root}>
<div className="span-line-action-buttons">
<TooltipProvider>
<TooltipRoot>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className={styles.copyBtn}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipTrigger>
<TooltipContent className={styles.tooltip}>Copy Span Link</TooltipContent>
</TooltipRoot>
<TooltipContent className="span-line-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);

View File

@@ -1,11 +0,0 @@
.root {
height: 100%;
display: flex;
flex-direction: column;
}
.loadingSkeleton {
justify-content: center;
align-items: center;
padding: 20px;
}

View File

@@ -0,0 +1,11 @@
.trace-waterfall {
height: 100%;
display: flex;
flex-direction: column;
.loading-skeleton {
justify-content: center;
align-items: center;
padding: 20px;
}
}

View File

@@ -13,7 +13,7 @@ import { getVisibleSpans } from './utils';
import { IInterestedSpan } from './types';
import styles from './TraceWaterfall.module.scss';
import './TraceWaterfall.styles.scss';
interface ITraceWaterfallProps {
traceId: string;
@@ -100,7 +100,7 @@ function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
switch (traceWaterfallState) {
case TraceWaterfallStates.LOADING:
return (
<div className={styles.loadingSkeleton}>
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
@@ -158,7 +158,7 @@ function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
uncollapsedNodes,
]);
return <div className={styles.root}>{getContent}</div>;
return <div className="trace-waterfall">{getContent}</div>;
}
export default TraceWaterfall;

View File

@@ -1,24 +0,0 @@
.root {
display: flex;
padding: 12px;
margin: 20px;
gap: 12px;
align-items: flex-start;
border-radius: 4px;
background: var(--destructive);
}
.text,
.value {
color: var(--destructive-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.text {
flex-shrink: 0;
}

View File

@@ -0,0 +1,30 @@
.error-waterfall {
display: flex;
padding: 12px;
margin: 20px;
gap: 12px;
align-items: flex-start;
border-radius: 4px;
background: var(--destructive);
.text {
color: var(--destructive-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
flex-shrink: 0;
}
.value {
color: var(--destructive-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}

View File

@@ -2,7 +2,7 @@ import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import styles from './Error.module.scss';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
@@ -12,16 +12,10 @@ function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className={styles.root}>
<Typography.Text className={styles.text}>
Something went wrong!
</Typography.Text>
<div className="error-waterfall">
<Typography.Text className="text">Something went wrong!</Typography.Text>
<Tooltip title={error?.message}>
<Typography.Text
className={styles.value}
title={error?.message}
truncate={1}
>
<Typography.Text className="value" title={error?.message} truncate={1}>
{error?.message}
</Typography.Text>
</Tooltip>

View File

@@ -1,136 +0,0 @@
.root {
display: flex;
align-items: center;
gap: 12px;
// QuerySearch child sets `query-builder-search-v2` globally; size it to the
// search container by reaching into the descendant.
:global(.query-builder-search-v2) {
width: 100%;
}
// ToggleGroup children use generated class names; nest the global selectors
// under the local row so they only apply inside this filter row.
:global([class*='toggle-group']) {
flex-shrink: 0;
:global([class*='toggle-group-item']) {
flex: 0 0 auto;
}
}
}
.isExpanded {
flex: 1;
}
.searchContainer {
flex: 1;
min-width: 0;
}
.pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--l1-border);
border-radius: 4px;
cursor: pointer;
max-width: 220px;
min-width: 120px;
height: 32px;
background: var(--l1-background);
&:hover {
border-color: var(--l3-border);
}
}
.pillText {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.pillIndicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-background);
flex-shrink: 0;
}
.pillPopover {
max-width: 400px;
}
.pillPopoverHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.pillPopoverExpression {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-all;
padding: 6px 8px;
background: var(--l2-background);
border-radius: 4px;
}
.collapseBtn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
.highlightErrorsToggle {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
}
.preNextToggle {
display: flex;
flex-shrink: 0;
gap: 12px;
}
.preNextCount {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.filterStatus {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.hasError {
color: var(--destructive);
cursor: help;
}

View File

@@ -0,0 +1,145 @@
.trace-v3-filter-row {
display: flex;
align-items: center;
gap: 12px;
&.expanded {
flex: 1;
}
.filter-search-container {
flex: 1;
min-width: 0;
}
.query-builder-search-v2 {
width: 100%;
}
// --- Collapsed pill ---
.filter-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--l1-border);
border-radius: 4px;
cursor: pointer;
max-width: 220px;
min-width: 120px;
height: 32px;
background: var(--l1-background);
&:hover {
border-color: var(--l3-border);
}
&__text {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-background);
flex-shrink: 0;
}
}
// --- Collapsed pill popover ---
.filter-pill-popover {
max-width: 400px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__expression {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-all;
padding: 6px 8px;
background: var(--l2-background);
border-radius: 4px;
}
}
// --- ToggleGroup override: size to content, don't stretch items ---
[class*='toggle-group'] {
flex-shrink: 0;
[class*='toggle-group-item'] {
flex: 0 0 auto;
}
}
// --- Collapse button ---
.filter-collapse-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
// --- Highlight errors toggle ---
.highlight-errors-toggle {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
}
// --- Prev/next navigation ---
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
&__count {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
}
.filter-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
&--error {
color: var(--destructive);
cursor: help;
}
}
}

View File

@@ -15,14 +15,13 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import {
TooltipRoot,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
@@ -43,7 +42,7 @@ import {
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import styles from './Filters.module.scss';
import './Filters.styles.scss';
function prepareQuery(filters: TagFilter, traceID: string): Query {
return {
@@ -256,7 +255,7 @@ function Filters({
);
const highlightErrorsToggle = (
<div className={styles.highlightErrorsToggle}>
<div className="highlight-errors-toggle">
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"
@@ -270,9 +269,9 @@ function Filters({
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<TooltipRoot>
<Tooltip>
<TooltipTrigger asChild>
<span className={cx(styles.filterStatus, styles.hasError)}>
<span className="filter-status filter-status--error">
<Info />
API error
</span>
@@ -280,10 +279,10 @@ function Filters({
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</TooltipRoot>
</Tooltip>
)}
{!error && noData && (
<Typography.Text className={styles.filterStatus}>
<Typography.Text className="filter-status">
No results found
</Typography.Text>
)}
@@ -294,22 +293,22 @@ function Filters({
if (!isExpanded) {
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className={styles.pill} onClick={onExpand}>
<div className="filter-pill" onClick={onExpand}>
<Search size={12} />
<span className={styles.pillText}>{expression || 'Search...'}</span>
{expression && <span className={styles.pillIndicator} />}
<span className="filter-pill__text">{expression || 'Search...'}</span>
{expression && <span className="filter-pill__indicator" />}
</div>
);
return (
<TooltipProvider>
<div className={styles.root}>
<div className="trace-v3-filter-row collapsed">
{expression ? (
<TooltipRoot>
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<div className="filter-pill-popover">
<div className="filter-pill-popover__header">
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
@@ -326,10 +325,10 @@ function Filters({
<Copy size={12} />
</Button>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
<div className="filter-pill-popover__expression">{expression}</div>
</div>
</TooltipContent>
</TooltipRoot>
</Tooltip>
) : (
pill
)}
@@ -343,7 +342,7 @@ function Filters({
// --- EXPANDED VIEW ---
return (
<TooltipProvider>
<div className={cx(styles.root, styles.isExpanded)}>
<div className="trace-v3-filter-row expanded">
<ToggleGroup
type="single"
value={selectedCategory}
@@ -362,7 +361,7 @@ function Filters({
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={styles.searchContainer}
className="filter-search-container"
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
@@ -383,8 +382,8 @@ function Filters({
/>
</div>
{filteredSpanIds.length > 0 && (
<div className={styles.preNextToggle}>
<Typography.Text className={styles.preNextCount}>
<div className="pre-next-toggle">
<Typography.Text className="pre-next-toggle__count">
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
@@ -417,7 +416,7 @@ function Filters({
variant="ghost"
size="icon"
color="secondary"
className={styles.collapseBtn}
className="filter-collapse-btn"
onClick={onCollapse}
>
<X size={14} />

View File

@@ -1,634 +0,0 @@
// Inside a .module.scss, postcss-modules auto-scopes `@keyframes` identifiers
// and rewrites `animation`/`animation-name` references to match.
@keyframes waterfallLoading {
0% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: left;
}
50% {
opacity: 1;
transform: scaleX(1);
transform-origin: left;
}
100% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: right;
}
}
.loadingBar {
height: 2px;
background: var(--primary);
animation: waterfallLoading 1.5s ease-in-out infinite;
flex-shrink: 0;
}
// Event-dot tooltip. Visually matches SpanHoverCard's popover; styles are
// inlined rather than `compose`d from another module because postcss-modules
// loads cross-module composes through the plain CSS parser, which chokes on
// SCSS `//` comments in the target file.
.popover {
--tooltip-z-index: 1000;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}
.root {
overflow-y: hidden;
overflow-x: hidden;
max-width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.missingSpans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
}
.leftInfo {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.rightInfo {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
&:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.splitPanel {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0px 20px 0px 20px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
.splitHeader {
position: sticky;
top: 0;
z-index: 2;
display: flex;
height: 25px;
background-color: var(--l1-background);
}
.sidebarHeader {
flex-shrink: 0;
}
.resizeHandleHeader {
width: 4px;
flex-shrink: 0;
}
.statusHeader {
width: 50px;
flex-shrink: 0;
}
.timelineHeader {
flex: 1;
overflow: hidden;
padding: 0 15px;
}
.splitBody {
display: flex;
position: relative;
}
// Invisible IntersectionObserver targets pinned at the top and bottom of the
// virtualized content. See `useBoundaryPagination`.
.loadMoreSentinel {
position: absolute;
left: 0;
right: 0;
height: 1px;
pointer-events: none;
}
.loadMoreSentinelTop {
top: 0;
}
.loadMoreSentinelBottom {
bottom: 0;
}
.sidebar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
// ResizableBox child renders with a global `.resizable-box__content` class
// — give it independent horizontal scrolling.
:global(.resizable-box__content) {
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
height: 0;
}
scrollbar-width: none;
}
&::-webkit-scrollbar {
height: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
border-radius: 4px;
}
}
.treeTable {
position: relative;
border-collapse: collapse;
}
.treeRow {
display: flex;
align-items: center;
}
.treeCell {
display: flex;
width: 100%;
height: 28px;
align-items: center;
overflow: visible;
padding: 0;
}
.treeRow:hover,
.treeRow.hoveredSpan {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
transparent
) !important;
.spanOverview {
background: unset !important;
}
}
.sidebarResizeHandle {
width: 4px;
flex-shrink: 0;
cursor: col-resize;
user-select: none;
touch-action: none;
background: transparent;
transition: background 0.15s ease;
z-index: 1;
&:hover,
&:active {
background: rgba(35, 196, 248, 0.2);
}
}
.statusCol {
width: 50px;
flex-shrink: 0;
position: relative;
}
.statusCell {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
--badge-line-height: 12px;
--badge-border-width: 0px;
&.hoveredSpan {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
transparent
) !important;
}
&.isInterested,
&.isSelectedNonMatching {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
transparent
) !important;
}
&.isDimmed {
opacity: 0.15;
}
}
.timeline {
flex: 1;
position: relative;
overflow: hidden;
}
.crosshair {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--l3-background);
pointer-events: none;
z-index: 1;
}
.timelineRow {
display: flex;
align-items: center;
// Match timelineHeader's 15px padding so bars align with ticks
padding: 0 15px;
&:hover,
&.hoveredSpan {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
transparent
) !important;
}
&:has(.isInterested),
&:has(.isSelectedNonMatching) {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
transparent
) !important;
}
}
.spanOverview {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
width: 100%;
cursor: pointer;
position: relative;
white-space: nowrap;
&:hover .rowActions {
opacity: 1;
pointer-events: auto;
}
&.isInterested,
&.isSelectedNonMatching {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
transparent
) !important;
}
}
.treeIndent {
display: inline-block;
flex-shrink: 0;
}
.treeLine {
position: absolute;
background-color: var(--l2-border);
pointer-events: none;
}
.treeConnector {
position: absolute;
width: 14px;
height: 50%;
border-left: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-bottom-left-radius: 6px;
pointer-events: none;
}
// Reserved horizontal space for the chevron — present on every row, filled
// only when the span has children. Keeps sibling icons aligned.
.treeArrowSlot {
width: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.treeArrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
color: var(--l1-foreground);
border-radius: 4px;
&:hover {
background: var(--l3-background);
}
}
// Reserved horizontal space for the subtree-count badge — same reason.
// Right-aligns the badge inside so single-digit counts don't push the icon
// left of where multi-digit counts would put it.
.subtreeCountSlot {
min-width: 34px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.subtreeCount {
display: inline-flex;
align-items: center;
flex-shrink: 0;
:global(.badge) {
font-size: 10px;
line-height: 14px;
padding: 0 4px;
height: 14px;
}
}
.treeIcon {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
margin: 0 6px;
&.hasError {
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.3);
}
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 13px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.treeServiceName {
margin-left: 18px;
color: var(--l3-foreground);
font-weight: 400;
}
.rowActions {
position: sticky;
right: 0;
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
padding-right: 4px;
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
}
.actionBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 2px;
cursor: pointer;
color: var(--l1-foreground);
background: transparent;
border: none;
padding: 0;
&:hover {
background: var(--l3-background);
}
}
// Also reveal the actions when the parent tree row is hovered.
.treeRow:hover .rowActions,
.treeRow.hoveredSpan .rowActions {
opacity: 1;
pointer-events: auto;
}
.spanDuration {
display: flex;
align-items: center;
height: 28px;
position: relative;
width: 100%;
padding: 0 15px;
cursor: pointer;
}
.spanBar {
position: absolute;
height: 18px;
top: 5px;
border-radius: 2px;
display: flex;
align-items: center;
padding: 0 8px;
overflow: hidden;
cursor: pointer;
white-space: nowrap;
color: rgba(0, 0, 0, 0.9);
background-color: var(--span-color);
border: 1px solid transparent;
}
.spanInfo {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
overflow: hidden;
z-index: 1;
}
.spanName {
font-weight: 500;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
min-width: 0;
}
.spanDurationText {
color: inherit;
opacity: 0.8;
font-size: 10px;
margin-left: 8px;
flex-shrink: 0;
}
.eventDot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 5px;
height: 5px;
background-color: var(--event-dot-bg, var(--bg-robin-500));
border: 1px solid var(--event-dot-border, var(--bg-robin-600));
cursor: pointer;
z-index: 1;
&.hasError {
background-color: var(--destructive);
border-color: var(--destructive);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
// Hover state: solid l2-background fill + border (matches flamegraph)
.timelineRow:hover .spanBar,
.timelineRow.hoveredSpan .spanBar {
color: var(--span-color);
background-color: var(--l2-background);
background-image: none;
border: 1px solid var(--span-color);
}
// Selected state: solid l3-background fill + dashed border (matches flamegraph)
.isInterested .spanBar,
.isSelectedNonMatching .spanBar {
color: var(--span-color);
background-color: var(--l2-background);
background-image: none;
border: 1px dashed var(--span-color);
}
.isDimmed {
opacity: 0.15;
}
.isHighlighted {
opacity: 1;
}
.isSelectedNonMatching {
.treeLabel {
opacity: 0.5;
}
}
// `.spanBar` text color is the one place where semantic tokens don't fit
// cleanly: in dark mode the bar's bright `--span-color` background needs dark
// text; in light mode `generateColor` produces darker bar fills, so the text
// must flip to white.
:global(.lightMode) {
.root {
.spanDuration .spanBar {
color: rgba(255, 255, 255, 0.9);
}
.timelineRow:hover .spanBar,
.timelineRow.hoveredSpan .spanBar,
.isInterested .spanBar,
.isSelectedNonMatching .spanBar {
color: var(--span-color);
}
}
}
// Tooltips for the row's hover-revealed action buttons (Copy / Add to Funnel).
// Bumped above FloatingPanel (z-index 999) so they stay visible when the
// SpanDetailsPanel is docked as a floating panel.
.actionTooltip {
--tooltip-z-index: 1000;
}

View File

@@ -0,0 +1,636 @@
.waterfall-loading-bar {
height: 2px;
background: var(--primary);
animation: waterfall-loading 1.5s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes waterfall-loading {
0% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: left;
}
50% {
opacity: 1;
transform: scaleX(1);
transform-origin: left;
}
100% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: right;
}
}
.success-content {
overflow-y: hidden;
overflow-x: hidden;
max-width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.missing-spans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
.left-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.right-info {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.right-info:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.waterfall-split-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0px 20px 0px 20px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
.waterfall-split-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
height: 25px;
background-color: var(--l1-background);
.sidebar-header {
flex-shrink: 0;
}
.resize-handle-header {
width: 4px;
flex-shrink: 0;
}
.status-header {
width: 50px;
flex-shrink: 0;
}
.timeline-header {
flex: 1;
overflow: hidden;
padding: 0 15px;
}
}
.waterfall-split-body {
display: flex;
position: relative;
}
// Invisible IntersectionObserver targets pinned at the top and bottom of
// the virtualized content. See `useBoundaryPagination`.
.waterfall-load-more-sentinel {
position: absolute;
left: 0;
right: 0;
height: 1px;
pointer-events: none;
&--top {
top: 0;
}
&--bottom {
bottom: 0;
}
}
.waterfall-sidebar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
.resizable-box__content {
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
height: 0;
}
scrollbar-width: none;
}
&::-webkit-scrollbar {
height: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
border-radius: 4px;
}
}
.span-tree-table {
position: relative;
border-collapse: collapse;
.span-tree-row {
display: flex;
align-items: center;
}
.span-tree-cell {
display: flex;
width: 100%;
height: 28px;
align-items: center;
overflow: visible;
padding: 0;
}
.span-tree-row:hover,
.span-tree-row.hovered-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
transparent
) !important;
.span-overview {
background: unset !important;
}
}
}
.sidebar-resize-handle {
width: 4px;
flex-shrink: 0;
cursor: col-resize;
user-select: none;
touch-action: none;
background: transparent;
transition: background 0.15s ease;
z-index: 1;
&:hover,
&:active {
background: rgba(35, 196, 248, 0.2);
}
}
.waterfall-status-col {
width: 50px;
flex-shrink: 0;
position: relative;
.status-cell {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
--badge-line-height: 12px;
--badge-border-width: 0px;
&.hovered-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
transparent
) !important;
}
&.interested-span,
&.selected-non-matching-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
transparent
) !important;
}
&.dimmed-span {
opacity: 0.15;
}
}
}
.waterfall-timeline {
flex: 1;
position: relative;
overflow: hidden;
.waterfall-crosshair {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--l3-background);
pointer-events: none;
z-index: 1;
}
.timeline-row {
display: flex;
align-items: center;
// Match timeline-header's 15px padding so bars align with ticks
padding: 0 15px;
}
.timeline-row:hover,
.timeline-row.hovered-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
transparent
) !important;
}
.timeline-row:has(.interested-span),
.timeline-row:has(.selected-non-matching-span) {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
transparent
) !important;
}
}
// Shared span component styles (used in both panels)
.span-overview {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
width: 100%;
cursor: pointer;
position: relative;
white-space: nowrap;
.tree-indent {
display: inline-block;
flex-shrink: 0;
}
.tree-line {
position: absolute;
background-color: var(--l2-border);
pointer-events: none;
}
.tree-connector {
position: absolute;
width: 14px;
height: 50%;
border-left: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-bottom-left-radius: 6px;
pointer-events: none;
}
// Reserved horizontal space for the chevron — present on every row,
// filled only when the span has children. Keeps sibling icons aligned.
.tree-arrow-slot {
width: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
color: var(--l1-foreground);
border-radius: 4px;
&:hover {
background: var(--l3-background);
}
}
// Reserved horizontal space for the subtree-count badge — same reason.
// Right-aligns the badge inside so single-digit counts don't push the
// icon left of where multi-digit counts would put it.
.subtree-count-slot {
min-width: 34px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.subtree-count {
display: inline-flex;
align-items: center;
flex-shrink: 0;
.badge {
font-size: 10px;
line-height: 14px;
padding: 0 4px;
height: 14px;
}
}
.tree-icon {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
margin: 0 6px;
&.is-error {
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.3);
}
}
.tree-label {
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 13px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
.tree-service-name {
margin-left: 18px;
color: var(--l3-foreground);
font-weight: 400;
}
}
.span-row-actions {
position: sticky;
right: 0;
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
padding-right: 4px;
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
.span-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 2px;
cursor: pointer;
color: var(--l1-foreground);
background: transparent;
border: none;
padding: 0;
&:hover {
background: var(--l3-background);
}
}
}
&:hover .span-row-actions {
opacity: 1;
pointer-events: auto;
}
}
// Also show action buttons when hovering the tree row (parent of span-overview)
.span-tree-row:hover .span-row-actions,
.span-tree-row.hovered-span .span-row-actions {
opacity: 1;
pointer-events: auto;
}
.span-duration {
display: flex;
align-items: center;
height: 28px;
position: relative;
width: 100%;
padding: 0 15px;
cursor: pointer;
.span-bar {
position: absolute;
height: 18px;
top: 5px;
border-radius: 2px;
display: flex;
align-items: center;
padding: 0 8px;
overflow: hidden;
cursor: pointer;
white-space: nowrap;
color: rgba(0, 0, 0, 0.9);
background-color: var(--span-color);
border: 1px solid transparent;
.span-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
overflow: hidden;
z-index: 1;
.span-name {
font-weight: 500;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
min-width: 0;
}
.span-duration-text {
color: inherit;
opacity: 0.8;
font-size: 10px;
margin-left: 8px;
flex-shrink: 0;
}
}
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 5px;
height: 5px;
background-color: var(--event-dot-bg, var(--bg-robin-500));
border: 1px solid var(--event-dot-border, var(--bg-robin-600));
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--destructive);
border-color: var(--destructive);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
// Hover state: solid l2-background fill + border (matches flamegraph)
.timeline-row:hover .span-bar,
.timeline-row.hovered-span .span-bar {
color: var(--span-color);
background-color: var(--l2-background);
background-image: none;
border: 1px solid var(--span-color);
}
// Selected state: solid l3-background fill + dashed border (matches flamegraph)
.interested-span .span-bar,
.selected-non-matching-span .span-bar {
color: var(--span-color);
background-color: var(--l2-background);
background-image: none;
border: 1px dashed var(--span-color);
}
// Shared state classes for both panels
// Background highlight for selection is on .timeline-row via :has() — see .waterfall-timeline
// Only apply on .span-overview (left panel) where there's no parent row with :has()
.span-overview.interested-span,
.span-overview.selected-non-matching-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
transparent
) !important;
}
.dimmed-span {
opacity: 0.15;
}
.highlighted-span {
opacity: 1;
}
.selected-non-matching-span {
.tree-label {
opacity: 0.5;
}
}
}
.span-dets {
.related-logs {
display: flex;
width: 160px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: none;
}
}
// `.span-bar` text color is the one place where semantic tokens don't fit
// cleanly: in dark mode the bar's bright `--span-color` background needs dark
// text; in light mode `generateColor` produces darker bar fills, so the text
// must flip to white. This is the only `.lightMode` carve-out left after the
// migration to semantic tokens — the hover/selected rules are repeated here
// to beat the default-state rule's specificity inside `.lightMode`.
.lightMode {
.success-content {
.span-duration .span-bar {
color: rgba(255, 255, 255, 0.9);
}
.timeline-row:hover .span-bar,
.timeline-row.hovered-span .span-bar,
.interested-span .span-bar,
.selected-non-matching-span .span-bar {
color: var(--span-color);
}
}
}
// Tooltips for the row's hover-revealed action buttons (Copy / Add to Funnel).
// Bumped above FloatingPanel (z-index 999) so they stay visible when the
// SpanDetailsPanel is docked as a floating panel.
.span-action-tooltip {
--tooltip-z-index: 1000;
}

View File

@@ -12,7 +12,7 @@ import {
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
TooltipRoot,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
@@ -53,7 +53,7 @@ import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
import styles from './Success.module.scss';
import './Success.styles.scss';
/**
* Lazy event dot — only mounts the tooltip when the user hovers.
@@ -91,7 +91,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const dot = (
<div
className={cx(styles.eventDot, isError && styles.hasError)}
className={`event-dot ${isError ? 'error' : ''}`}
style={
{
left: `${dotLeft}%`,
@@ -112,16 +112,16 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
return (
<TooltipProvider>
<TooltipRoot
<Tooltip
open
onOpenChange={(open: boolean): void => {
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
<TooltipTrigger asChild>{dot}</TooltipTrigger>
<TooltipContent className={styles.popover}>
<TooltipContent className="span-hover-card-popover">
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
@@ -129,7 +129,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
attributeMap={event.attributeMap || {}}
/>
</TooltipContent>
</TooltipRoot>
</Tooltip>
</TooltipProvider>
);
});
@@ -243,11 +243,11 @@ const SpanOverview = memo(function SpanOverview({
return (
<div
className={cx(styles.spanOverview, {
[styles.isInterested]: isSelected && (!isFilterActive || isMatching),
[styles.isHighlighted]: isHighlighted,
[styles.isSelectedNonMatching]: isSelectedNonMatching,
[styles.isDimmed]: isDimmed,
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
onMouseEnter={(): void => onHoverEnter(span.span_id)}
@@ -264,7 +264,7 @@ const SpanOverview = memo(function SpanOverview({
return (
<div
key={lvl}
className={styles.treeLine}
className="tree-line"
style={{
left: xPos,
top: 0,
@@ -277,25 +277,25 @@ const SpanOverview = memo(function SpanOverview({
return (
<div key={lvl}>
<div
className={styles.treeLine}
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className={styles.treeConnector} style={{ left: xPos, top: 0 }} />
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
);
})}
{/* Indent spacer */}
<span className={styles.treeIndent} style={{ width: `${indentWidth}px` }} />
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Expand/collapse arrow + child count slots — always render the
slots, fill them only when the span has children. Reserving the
horizontal space on leaf rows aligns sibling icons regardless
of whether each sibling is a parent or a leaf. */}
<span className={styles.treeArrowSlot}>
<span className="tree-arrow-slot">
{span.has_children && (
<span
className={styles.treeArrow}
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
@@ -306,9 +306,9 @@ const SpanOverview = memo(function SpanOverview({
</span>
)}
</span>
<span className={styles.subtreeCountSlot}>
<span className="subtree-count-slot">
{span.has_children && (
<span className={styles.subtreeCount}>
<span className="subtree-count">
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</span>
)}
@@ -316,51 +316,51 @@ const SpanOverview = memo(function SpanOverview({
{/* Colored service dot */}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
className={cx('tree-icon', { 'is-error': span.has_error })}
style={{ backgroundColor: color }}
/>
{/* Span name + service name */}
<span className={styles.treeLabel}>
<span className="tree-label">
{span.name}
<span className={styles.treeServiceName}>{span['service.name']}</span>
<span className="tree-service-name">{span['service.name']}</span>
</span>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className={styles.rowActions}>
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<TooltipRoot>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.actionBtn}
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className={styles.actionTooltip}>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</TooltipRoot>
<TooltipRoot>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.actionBtn}
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className={styles.actionTooltip}>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</TooltipRoot>
</Tooltip>
</TooltipProvider>
</span>
</div>
@@ -410,16 +410,16 @@ export const SpanDuration = memo(function SpanDuration({
return (
<div
className={cx(styles.spanDuration, {
[styles.isInterested]: isSelected && (!isFilterActive || isMatching),
[styles.isHighlighted]: isHighlighted,
[styles.isSelectedNonMatching]: isSelectedNonMatching,
[styles.isDimmed]: isDimmed,
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
<div
className={styles.spanBar}
className="span-bar"
style={
{
left: `${leftOffset}%`,
@@ -429,9 +429,9 @@ export const SpanDuration = memo(function SpanDuration({
} as React.CSSProperties
}
>
<span className={styles.spanInfo}>
<span className={styles.spanName}>{span.name}</span>
<span className={styles.spanDurationText}>{`${toFixed(
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
@@ -529,11 +529,11 @@ function Success(props: ISuccessProps): JSX.Element {
if (prev) {
const prevElements = document.querySelectorAll(`[data-span-id="${prev}"]`);
prevElements.forEach((el) => el.classList.remove(styles.hoveredSpan));
prevElements.forEach((el) => el.classList.remove('hovered-span'));
}
if (spanId) {
const nextElements = document.querySelectorAll(`[data-span-id="${spanId}"]`);
nextElements.forEach((el) => el.classList.add(styles.hoveredSpan));
nextElements.forEach((el) => el.classList.add('hovered-span'));
}
prevHoveredSpanIdRef.current = spanId;
}, []);
@@ -798,17 +798,17 @@ function Success(props: ISuccessProps): JSX.Element {
}, []);
return (
<div className={styles.root}>
<div className="success-content">
{traceMetadata.hasMissingSpans && (
<div className={styles.missingSpans}>
<section className={styles.leftInfo}>
<div className="missing-spans">
<section className="left-info">
<CircleAlert size={14} />
<span className={styles.text}>This trace has missing spans</span>
<span className="text">This trace has missing spans</span>
</section>
<Button
variant="ghost"
color="secondary"
className={styles.rightInfo}
className="right-info"
suffix={<ArrowUpRight size={14} />}
onClick={(): WindowProxy | null =>
window.open(
@@ -821,17 +821,17 @@ function Success(props: ISuccessProps): JSX.Element {
</Button>
</div>
)}
{isFetching && <div className={styles.loadingBar} />}
<div className={styles.splitPanel} ref={scrollContainerRef}>
{isFetching && <div className="waterfall-loading-bar" />}
<div className="waterfall-split-panel" ref={scrollContainerRef}>
{/* Sticky header row */}
<div className={styles.splitHeader}>
<div className="waterfall-split-header">
<div
className={styles.sidebarHeader}
className="sidebar-header"
style={{ width: sidebarWidth, flexShrink: 0 }}
/>
<div className={styles.resizeHandleHeader} />
<div className={styles.statusHeader} />
<div className={styles.timelineHeader}>
<div className="resize-handle-header" />
<div className="status-header" />
<div className="timeline-header">
<TimelineV3
startTimestamp={traceMetadata.startTime}
endTimestamp={traceMetadata.endTime}
@@ -844,7 +844,7 @@ function Success(props: ISuccessProps): JSX.Element {
{/* Split body */}
<div
className={styles.splitBody}
className="waterfall-split-body"
style={{
minHeight: virtualizer.getTotalSize(),
height: '100%',
@@ -854,11 +854,11 @@ function Success(props: ISuccessProps): JSX.Element {
fires a load-more via useBoundaryPagination. */}
<div
ref={loadMoreTopSentinelRef}
className={cx(styles.loadMoreSentinel, styles.loadMoreSentinelTop)}
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--top"
/>
<div
ref={loadMoreBottomSentinelRef}
className={cx(styles.loadMoreSentinel, styles.loadMoreSentinelBottom)}
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--bottom"
/>
<SpanHoverCard
hoveredSpanId={hoveredSpanId}
@@ -875,9 +875,9 @@ function Success(props: ISuccessProps): JSX.Element {
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
onResize={setSidebarWidth}
className={styles.sidebar}
className="waterfall-sidebar"
>
<table className={styles.treeTable} style={{ width: maxContentWidth }}>
<table className="span-tree-table" style={{ width: maxContentWidth }}>
<tbody>
{virtualItems.map((virtualRow) => {
const row = leftRows[virtualRow.index];
@@ -887,7 +887,7 @@ function Success(props: ISuccessProps): JSX.Element {
key={String(virtualRow.key)}
data-testid={`cell-0-${span.span_id}`}
data-span-id={span.span_id}
className={styles.treeRow}
className="span-tree-row"
style={{
position: 'absolute',
top: 0,
@@ -900,7 +900,7 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={handleRowMouseLeave}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className={styles.treeCell}>
<td key={cell.id} className="span-tree-cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
@@ -912,7 +912,7 @@ function Success(props: ISuccessProps): JSX.Element {
</ResizableBox>
{/* Status code column */}
<div className={styles.statusCol}>
<div className="waterfall-status-col">
{virtualItems.map((virtualRow) => {
const span = spans[virtualRow.index];
const { isSelected, isDimmed, isSelectedNonMatching, isMatching } =
@@ -925,10 +925,10 @@ function Success(props: ISuccessProps): JSX.Element {
return (
<div
key={`status-${String(virtualRow.key)}`}
className={cx(styles.statusCell, {
[styles.isInterested]: isSelected && (!isFilterActive || isMatching),
[styles.isDimmed]: isDimmed,
[styles.isSelectedNonMatching]: isSelectedNonMatching,
className={cx('status-cell', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'dimmed-span': isDimmed,
'selected-non-matching-span': isSelectedNonMatching,
})}
style={{
position: 'absolute',
@@ -953,13 +953,13 @@ function Success(props: ISuccessProps): JSX.Element {
{/* Right panel - timeline bars */}
<div
className={styles.timeline}
className="waterfall-timeline"
ref={timelineAreaRef}
onMouseMove={onCrosshairMove}
onMouseLeave={onCrosshairLeave}
>
{cursorX !== null && (
<div className={styles.crosshair} style={{ left: cursorX }} />
<div className="waterfall-crosshair" style={{ left: cursorX }} />
)}
{virtualItems.map((virtualRow) => {
const span = spans[virtualRow.index];
@@ -968,7 +968,7 @@ function Success(props: ISuccessProps): JSX.Element {
key={String(virtualRow.key)}
data-testid={`cell-1-${span.span_id}`}
data-span-id={span.span_id}
className={styles.timelineRow}
className="timeline-row"
style={{
position: 'absolute',
top: 0,

View File

@@ -2,33 +2,18 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
// Local identity-proxy mock for this module so `styles.foo` resolves to
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
// `export default {}`; we override resolution for this specific file only.
jest.mock(
'../Success.module.scss',
() =>
new Proxy(
{},
{
get: (_target, prop): string | undefined =>
typeof prop === 'string' && prop !== '__esModule' ? prop : undefined,
},
),
);
import { SpanDuration } from '../Success';
import successStyles from '../Success.module.scss';
const renderWithTraceProvider: typeof render = (ui, options) =>
render(ui, options);
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = `.${successStyles.spanDuration}`;
const INTERESTED_SPAN_CLASS = successStyles.isInterested;
const HIGHLIGHTED_SPAN_CLASS = successStyles.isHighlighted;
const DIMMED_SPAN_CLASS = successStyles.isDimmed;
const SELECTED_NON_MATCHING_SPAN_CLASS = successStyles.isSelectedNonMatching;
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
jest.mock('components/TimelineV3/TimelineV3', () => ({
__esModule: true,
@@ -142,7 +127,10 @@ describe('SpanDuration', () => {
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
// Hover over the span should add hovered-span class
fireEvent.mouseEnter(spanElement);
// Mouse leave should remove hovered-span class
fireEvent.mouseLeave(spanElement);
});
@@ -271,8 +259,8 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);

View File

@@ -2,23 +2,7 @@ import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
// Local identity-proxy mock for this module so `styles.foo` resolves to
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
// `export default {}`; we override resolution for this specific file only.
jest.mock(
'../Success.module.scss',
() =>
new Proxy(
{},
{
get: (_target, prop): string | undefined =>
typeof prop === 'string' && prop !== '__esModule' ? prop : undefined,
},
),
);
import Success from '../Success';
import successStyles from '../Success.module.scss';
const renderWithTraceProvider: typeof render = (ui, options, customOptions) =>
render(ui, options, customOptions);
@@ -227,10 +211,10 @@ describe('Span Click User Flows', () => {
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
const SPAN_OVERVIEW_CLASS = '.span-overview';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
const SPAN_OVERVIEW_CLASS = `.${successStyles.spanOverview}`;
const SPAN_DURATION_CLASS = `.${successStyles.spanDuration}`;
const INTERESTED_SPAN_CLASS = successStyles.isInterested;
beforeEach(() => {
jest.clearAllMocks();
@@ -284,6 +268,7 @@ describe('Span Click User Flows', () => {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
@@ -292,12 +277,14 @@ describe('Span Click User Flows', () => {
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click on span-2 to test selection change
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
await user.click(span2DurationElement);
// Wait for the state update and re-render
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
@@ -320,6 +307,7 @@ describe('Span Click User Flows', () => {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
@@ -330,16 +318,19 @@ describe('Span Click User Flows', () => {
SPAN_DURATION_CLASS,
) as HTMLElement;
// Initially both areas should show the same visual selection (first span is auto-selected)
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click span-2 to test selection change
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
@@ -350,15 +341,17 @@ describe('Span Click User Flows', () => {
SPAN_DURATION_CLASS,
) as HTMLElement;
// Now span-2 should be selected, span-1 should not
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
const span2OverviewSub = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2DurationSub = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2OverviewElement = span2OverviewSub.querySelector(
// Check that span-2 is selected
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2OverviewElement = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2DurationElement = span2DurationSub.querySelector(
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
@@ -374,6 +367,7 @@ describe('Span Click User Flows', () => {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
@@ -382,24 +376,27 @@ describe('Span Click User Flows', () => {
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click second span
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2OverviewSub = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2ElementSub = span2OverviewSub.querySelector(
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
// Second span should be selected, first should not
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2ElementSub).toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
@@ -429,6 +426,7 @@ describe('Span Click User Flows', () => {
{ initialRoute: '/trace' },
);
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
@@ -440,6 +438,7 @@ describe('Span Click User Flows', () => {
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
// Verify navigation was called with all parameters
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,

View File

@@ -15,13 +15,10 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import styles from './FieldsSettings.module.scss';
function SortableField({
field,
onRemove,
@@ -43,17 +40,14 @@ function SortableField({
<div
ref={setNodeRef}
style={style}
className={cx(
styles.fieldItem,
allowDrag ? styles.isDragEnabled : styles.isDragDisabled,
)}
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className={styles.dragHandle}>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={14} />}
<span className={styles.fieldKey}>{field.key}</span>
<span className="fs-field-key">{field.key}</span>
</div>
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
className="remove-field-btn periscope-btn"
variant="outlined"
color="destructive"
size="sm"
@@ -100,9 +94,9 @@ function AddedFields({
const allowDrag = inputValue.length === 0;
return (
<div className={cx(styles.section, styles.sectionAdded)}>
<div className={styles.sectionHeader}>ADDED FIELDS</div>
<div className={styles.addedList}>
<div className="fs-section fs-added">
<div className="fs-section-header">ADDED FIELDS</div>
<div className="fs-added-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
@@ -110,7 +104,7 @@ function AddedFields({
onDragEnd={handleDragEnd}
>
{filteredFields.length === 0 ? (
<div className={styles.noValues}>No values found</div>
<div className="fs-no-values">No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.key)}

View File

@@ -1,161 +0,0 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
background: var(--l1-background);
color: var(--l1-foreground);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
}
.title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
}
.closeIcon {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--l1-foreground);
}
}
.searchInput {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
}
.section {
display: flex;
flex-direction: column;
}
.sectionAdded {
max-height: 40%;
border-bottom: 1px solid var(--l1-border);
}
.sectionOther {
flex: 1;
min-height: 0;
}
.sectionHeader {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
.addedList {
overflow: hidden;
}
.otherList {
flex: 1;
min-height: 0;
overflow: hidden;
// Ant Skeleton.Input rendered inside the loading state — override its
// hard-coded width.
:global(.ant-skeleton-input) {
width: 300px;
margin: 8px 12px;
}
}
.noValues {
padding: 8px;
text-align: center;
color: var(--l2-foreground);
font-size: 12px;
}
.limitHint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
.fieldItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
&:hover {
background-color: var(--l2-background);
.removeBtn,
.addBtn {
opacity: 1;
}
}
}
.dragHandle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.fieldKey {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.isDragEnabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
.isDragDisabled {
padding: 6px 12px;
}
.otherFieldItem {
height: 32px;
}
.removeBtn,
.addBtn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
.footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--l1-border);
justify-content: space-between;
}

View File

@@ -0,0 +1,161 @@
.fields-settings {
display: flex;
flex-direction: column;
height: 100%;
background: var(--l1-background);
color: var(--l1-foreground);
overflow: hidden;
.fs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
.fs-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
}
.fs-close-icon {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
.fs-search {
.fs-search-input {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
}
}
.fs-section {
display: flex;
flex-direction: column;
&.fs-added {
max-height: 40%;
border-bottom: 1px solid var(--l1-border);
}
&.fs-other {
flex: 1;
min-height: 0;
}
}
.fs-section-header {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
.fs-added-list {
overflow: hidden;
}
.fs-other-list {
flex: 1;
min-height: 0;
overflow: hidden;
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.fs-no-values {
padding: 8px;
text-align: center;
color: var(--l2-foreground);
font-size: 12px;
}
.fs-limit-hint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
}
.fs-field-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.fs-field-key {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 6px 12px;
}
&.other-field-item {
height: 32px;
}
.remove-field-btn,
.add-field-btn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
&:hover {
background-color: var(--l2-background);
.remove-field-btn,
.add-field-btn {
opacity: 1;
}
}
}
.fs-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--l1-border);
justify-content: space-between;
}

View File

@@ -10,7 +10,7 @@ import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import styles from './FieldsSettings.module.scss';
import './FieldsSettings.styles.scss';
const MAX_FIELDS_DEFAULT = 10;
@@ -89,18 +89,18 @@ function FieldsSettings({
const isAtLimit = draftFields.length >= maxFields;
return (
<div className={styles.root}>
<div className={styles.header}>
<div className={styles.title}>
<div className="fields-settings">
<div className="fs-header">
<div className="fs-title">
<TableColumnsSplit size={16} />
{title}
</div>
<X className={styles.closeIcon} size={16} onClick={onClose} />
<X className="fs-close-icon" size={16} onClick={onClose} />
</div>
<section>
<section className="fs-search">
<Input
className={styles.searchInput}
className="fs-search-input"
type="text"
value={inputValue}
placeholder="Search for a field..."
@@ -123,7 +123,7 @@ function FieldsSettings({
/>
{hasUnsavedChanges && (
<div className={styles.footer}>
<div className="fs-footer">
<Button
variant="outlined"
color="secondary"

View File

@@ -1,15 +1,12 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import styles from './FieldsSettings.module.scss';
interface OtherFieldsProps {
dataSource: DataSource;
debouncedInputValue: string;
@@ -53,9 +50,9 @@ function OtherFields({
if (isFetching) {
return (
<div className={cx(styles.section, styles.sectionOther)}>
<div className={styles.sectionHeader}>OTHER FIELDS</div>
<div className={styles.otherList}>
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
@@ -66,23 +63,20 @@ function OtherFields({
}
return (
<div className={cx(styles.section, styles.sectionOther)}>
<div className={styles.sectionHeader}>OTHER FIELDS</div>
<div className={styles.otherList}>
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
<OverlayScrollbar>
<>
{otherFields.length === 0 ? (
<div className={styles.noValues}>No values found</div>
<div className="fs-no-values">No values found</div>
) : (
otherFields.map((attr) => (
<div
key={attr.key}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<span className={styles.fieldKey}>{attr.key}</span>
<div key={attr.key} className="fs-field-item other-field-item">
<span className="fs-field-key">{attr.key}</span>
{!isAtLimit && (
<Button
className={cx(styles.addBtn, 'periscope-btn')}
className="add-field-btn periscope-btn"
variant="outlined"
color="secondary"
size="sm"
@@ -94,7 +88,7 @@ function OtherFields({
</div>
))
)}
{isAtLimit && <div className={styles.limitHint}>Maximum 10 fields</div>}
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>

View File

@@ -32,9 +32,7 @@ import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
import './TraceDetailsV3.styles.scss';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
@@ -287,7 +285,7 @@ function TraceDetailsV3(): JSX.Element {
return (
<TraceStoreSync aggregations={traceData?.payload?.aggregations}>
<div className={styles.root}>
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
@@ -299,20 +297,20 @@ function TraceDetailsV3(): JSX.Element {
<NoData />
) : (
<>
<div className={styles.content}>
<div className="trace-details-v3__content">
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className={styles.flameCollapse}
className="trace-details-v3__flame-collapse"
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
<div className="trace-details-v3__collapse-label">
<span className="trace-details-v3__collapse-title">
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
@@ -323,15 +321,17 @@ function TraceDetailsV3(): JSX.Element {
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<span className="trace-details-v3__collapse-count">
<span className="trace-details-v3__collapse-count-item">
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
className={`trace-details-v3__collapse-count-item${
traceData.payload.totalErrorSpansCount > 0
? ' trace-details-v3__collapse-count-errors'
: ''
}`}
>
<TriangleAlert size={13} />
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
@@ -360,9 +360,11 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
className={`trace-details-v3__waterfall-collapse${
isWaterfallDocked
? ' trace-details-v3__waterfall-collapse--docked'
: ''
}`}
items={[
{
key: 'waterfall',
@@ -373,7 +375,7 @@ function TraceDetailsV3(): JSX.Element {
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<div className="trace-details-v3__docked-span-details">
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}

View File

@@ -162,24 +162,5 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/jobs", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListJobs),
handler.OpenAPIDef{
ID: "ListJobs",
Tags: []string{"inframonitoring"},
Summary: "List Jobs for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostableJobs),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Jobs),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -7,14 +7,11 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addRoleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.Create), handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
@@ -26,14 +23,12 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.List), handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
@@ -45,14 +40,12 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.Get), handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
@@ -64,14 +57,12 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.GetObjects, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.GetObjects), handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
@@ -83,14 +74,12 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Patch, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.Patch), handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
@@ -102,14 +91,12 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.PatchObjects, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.PatchObjects), handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
@@ -121,14 +108,12 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authzHandler.Delete), handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
@@ -140,33 +125,10 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func roleCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleInstanceSelectorCallback(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -17,7 +17,7 @@ import (
)
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceMetaResourcesServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateServiceAccount",
@@ -31,12 +31,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcesServiceAccount.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourcesServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccounts",
@@ -50,7 +50,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcesServiceAccount.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -135,10 +135,10 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleDetachSelectorFromPath, Roles: []string{
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromPath, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
@@ -153,7 +153,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -213,13 +213,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.CreateFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbCreate}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyCollectionSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
@@ -232,12 +227,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyCollectionSelectorCallback, []string{
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
@@ -251,12 +246,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyInstanceSelectorCallback, []string{
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
@@ -270,18 +265,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.RevokeFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDelete}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
@@ -294,7 +284,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -302,7 +292,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return nil
}
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
func (provider *provider) roleAttachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
if err != nil {
return nil, err
@@ -343,28 +333,9 @@ func (provider *provider) roleAttachSelectorFromBody(req *http.Request, claims a
}, nil
}
func factorAPIKeyCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
fid := mux.Vars(req)["fid"]
fidSelector, err := coretypes.TypeMetaResource.Selector(fid)
if err != nil {
return nil, err
}
return []coretypes.Selector{
fidSelector,
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
coretypes.TypeMetaResources.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -169,7 +169,7 @@ func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
return
}
render.Success(rw, http.StatusNoContent, nil)
render.Success(rw, http.StatusAccepted, nil)
}
func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {

View File

@@ -213,27 +213,3 @@ func (h *handler) ListStatefulSets(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListJobs(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.PostableJobs
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.ListJobs(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -1,156 +0,0 @@
package implinframonitoring
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// buildJobRecords assembles the page records. Pod phase counts come from
// phaseCounts in both modes; every row is a group of pods (one job in
// list mode, an arbitrary roll-up in grouped_list mode), so there's no
// per-row "current phase" concept.
func buildJobRecords(
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
) []inframonitoringtypes.JobRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.JobRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
jobName := labels[jobNameAttrKey]
record := inframonitoringtypes.JobRecord{ // initialize with default values
JobName: jobName,
JobCPU: -1,
JobCPURequest: -1,
JobCPULimit: -1,
JobMemory: -1,
JobMemoryRequest: -1,
JobMemoryLimit: -1,
DesiredSuccessfulPods: -1,
ActivePods: -1,
FailedPods: -1,
SuccessfulPods: -1,
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["A"]; exists {
record.JobCPU = v
}
if v, exists := metrics["B"]; exists {
record.JobCPURequest = v
}
if v, exists := metrics["C"]; exists {
record.JobCPULimit = v
}
if v, exists := metrics["D"]; exists {
record.JobMemory = v
}
if v, exists := metrics["E"]; exists {
record.JobMemoryRequest = v
}
if v, exists := metrics["F"]; exists {
record.JobMemoryLimit = v
}
if v, exists := metrics["H"]; exists {
record.DesiredSuccessfulPods = int(v)
}
if v, exists := metrics["I"]; exists {
record.ActivePods = int(v)
}
if v, exists := metrics["J"]; exists {
record.FailedPods = int(v)
}
if v, exists := metrics["K"]; exists {
record.SuccessfulPods = int(v)
}
}
if phaseCountsForGroup, ok := phaseCounts[compositeKey]; ok {
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
Pending: phaseCountsForGroup.Pending,
Running: phaseCountsForGroup.Running,
Succeeded: phaseCountsForGroup.Succeeded,
Failed: phaseCountsForGroup.Failed,
Unknown: phaseCountsForGroup.Unknown,
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
func (m *module) getTopJobGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableJobs,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
queryNamesForOrderBy := orderByToJobsQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newJobsTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
func (m *module) getJobsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range jobAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -1,278 +0,0 @@
package implinframonitoring
import (
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
jobNameAttrKey = "k8s.job.name"
jobsBaseFilterExpr = "k8s.job.name != ''"
)
var jobNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: jobNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
// jobsTableMetricNamesList drives the existence/retention check.
// Includes k8s.pod.phase even though phase isn't part of the QB composite query —
// it is queried separately via getPerGroupPodPhaseCounts, and we want the
// response to short-circuit cleanly when the phase metric is absent.
var jobsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",
"k8s.pod.memory.working_set",
"k8s.pod.memory_request_utilization",
"k8s.pod.memory_limit_utilization",
"k8s.job.active_pods",
"k8s.job.failed_pods",
"k8s.job.successful_pods",
"k8s.job.desired_successful_pods",
}
// Carried forward from v1 jobAttrsToEnrich
// (pkg/query-service/app/inframetrics/jobs.go:31-35).
var jobAttrKeysForMetadata = []string{
"k8s.job.name",
"k8s.namespace.name",
"k8s.cluster.name",
}
// orderByToJobsQueryNames maps the orderBy column to the query name
// used for ranking job groups. v2 B/C/E/F are direct metrics, no
// formula deps — so unlike v1 we don't carry A/D.
var orderByToJobsQueryNames = map[string][]string{
inframonitoringtypes.JobsOrderByCPU: {"A"},
inframonitoringtypes.JobsOrderByCPURequest: {"B"},
inframonitoringtypes.JobsOrderByCPULimit: {"C"},
inframonitoringtypes.JobsOrderByMemory: {"D"},
inframonitoringtypes.JobsOrderByMemoryRequest: {"E"},
inframonitoringtypes.JobsOrderByMemoryLimit: {"F"},
inframonitoringtypes.JobsOrderByDesiredSuccessfulPods: {"H"},
inframonitoringtypes.JobsOrderByActivePods: {"I"},
inframonitoringtypes.JobsOrderByFailedPods: {"J"},
inframonitoringtypes.JobsOrderBySuccessfulPods: {"K"},
}
// newJobsTableListQuery builds the composite QB v5 request for the jobs list.
// Ten builder queries: A..F roll up pod-level metrics by job, H/I/J/K take the
// latest job-level desired/active/failed/successful counts. Restarts (v1 query G)
// is intentionally omitted to match the v2 pods/deployments pattern.
//
// Every builder query carries the base filter `jobsBaseFilterExpr`. Reason:
// pod-level metrics (A..F) are emitted for every pod regardless of whether the
// pod belongs to a Job; only Job-owned pods carry the `k8s.job.name` resource
// attribute. Without this filter, standalone pods and pods owned by other
// workloads (Deployment/StatefulSet/DaemonSet/...) collapse into a single
// empty-string group under the default groupBy.
func (m *module) newJobsTableListQuery() *qbtypes.QueryRangeRequest {
queries := []qbtypes.QueryEnvelope{
// Query A: k8s.pod.cpu.usage — sum of pod CPU within the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query B: k8s.pod.cpu_request_utilization — avg across pods in the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu_request_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query C: k8s.pod.cpu_limit_utilization — avg across pods in the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu_limit_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query D: k8s.pod.memory.working_set — sum of pod memory within the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.memory.working_set",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query E: k8s.pod.memory_request_utilization — avg across pods in the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "E",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.memory_request_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query F: k8s.pod.memory_limit_utilization — avg across pods in the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "F",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.memory_limit_utilization",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query H: k8s.job.desired_successful_pods — latest known desired completion count per group.
// v1 used TimeAggregationAnyLast (v3) → mapped to TimeAggregationLatest in v5;
// SpaceAggregationSum + ReduceToLast preserve v1's "latest, summed across the group".
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "H",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.job.desired_successful_pods",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToLast,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query I: k8s.job.active_pods — latest known active pod count per group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "I",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.job.active_pods",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToLast,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query J: k8s.job.failed_pods — cumulative failed pod count per group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "J",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.job.failed_pods",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToLast,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
// Query K: k8s.job.successful_pods — cumulative successful pod count per group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "K",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.job.successful_pods",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToLast,
},
},
Filter: &qbtypes.Filter{Expression: jobsBaseFilterExpr},
GroupBy: []qbtypes.GroupByKey{jobNameGroupByKey},
Disabled: false,
},
},
}
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
}
}

View File

@@ -803,100 +803,3 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
return resp, nil
}
func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (*inframonitoringtypes.Jobs, error) {
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.Jobs{}
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.JobsOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{jobNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
// Bake the jobs base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &qbtypes.Filter{}
}
req.Filter.Expression = mergeFilterExpressions(jobsBaseFilterExpr, req.Filter.Expression)
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, jobsTableMetricNamesList)
if err != nil {
return nil, err
}
if len(missingMetrics) > 0 {
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
resp.Records = []inframonitoringtypes.JobRecord{}
resp.Total = 0
return resp, nil
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.JobRecord{}
resp.Total = 0
return resp, nil
}
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
metadataMap, err := m.getJobsTableMetadata(ctx, req)
if err != nil {
return nil, err
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopJobGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.JobRecord{}
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods. Pods owned by a Job carry
// k8s.job.name as a resource attribute, so default-groupBy gives
// per-job phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildJobRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -17,7 +17,6 @@ type Handler interface {
ListVolumes(http.ResponseWriter, *http.Request)
ListDeployments(http.ResponseWriter, *http.Request)
ListStatefulSets(http.ResponseWriter, *http.Request)
ListJobs(http.ResponseWriter, *http.Request)
}
type Module interface {
@@ -29,5 +28,4 @@ type Module interface {
ListVolumes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableVolumes) (*inframonitoringtypes.Volumes, error)
ListDeployments(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (*inframonitoringtypes.Deployments, error)
ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (*inframonitoringtypes.StatefulSets, error)
ListJobs(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (*inframonitoringtypes.Jobs, error)
}

View File

@@ -200,7 +200,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore),
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateMetaresourcesTuplesFactory(sqlstore),
)
}

View File

@@ -1,175 +0,0 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type migrateMetaresourcesTuples struct {
sqlstore sqlstore.SQLStore
}
func NewMigrateMetaresourcesTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_metaresources_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateMetaresourcesTuples{sqlstore: sqlstore}, nil
})
}
func (migration *migrateMetaresourcesTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// migrationTuple describes a single FGA tuple to insert.
type migrationTuple struct {
roleName string // "signoz-admin", "signoz-editor", "signoz-viewer"
objectType string // "serviceaccount", "user", "role", "metaresource"
objectName string // "serviceaccount", "user", "role", etc.
relation string // "create", "list", "detach", etc.
}
func (migration *migrateMetaresourcesTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
// Fetch all orgs.
var orgIDs []string
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var orgID string
if err := rows.Scan(&orgID); err != nil {
return err
}
orgIDs = append(orgIDs, orgID)
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// Step 1: Delete all tuples with the old "metaresources" object_type.
for _, orgID := range orgIDs {
if isPG {
_, err = tx.ExecContext(ctx, `DELETE FROM tuple WHERE store = ? AND object_type = ? AND object_id LIKE ?`,
storeID, "metaresources", "organization/"+orgID+"/%")
} else {
_, err = tx.ExecContext(ctx, `DELETE FROM tuple WHERE store = ? AND object_type = ? AND object_id LIKE ?`,
storeID, "metaresources", "organization/"+orgID+"/%")
}
if err != nil {
return err
}
}
// Step 2: Insert replacement tuples.
// For types with their own FGA type (user, serviceaccount, role), create/list
// go on the type directly. For all other resources, create/list go on "metaresource".
// Also add new detach tuples for role/user/serviceaccount.
tuples := []migrationTuple{
// New detach tuples for admin
{authtypes.SigNozAdminRoleName, "role", "role", "detach"},
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "detach"},
// Replacement create/list for user/serviceaccount/role (moved from metaresources to own types)
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "create"},
{authtypes.SigNozAdminRoleName, "serviceaccount", "serviceaccount", "list"},
{authtypes.SigNozAdminRoleName, "role", "role", "create"},
{authtypes.SigNozAdminRoleName, "role", "role", "list"},
// Replacement create/list for resources that move from "metaresources" to "metaresource"
{authtypes.SigNozAdminRoleName, "metaresource", "factor-api-key", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-api-key", "list"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-api-key", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-api-key", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-api-key", "delete"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *migrateMetaresourcesTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -200,7 +200,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
`CASE
// WHEN tagType = 'spanfield' THEN 1
WHEN tagType = 'resource' THEN 2
// WHEN tagType = 'scope' THEN 3
WHEN tagType = 'scope' THEN 3
WHEN tagType = 'tag' THEN 4
ELSE 5
END as priority`,

View File

@@ -51,6 +51,7 @@ var (
ValueType: schema.ColumnTypeString,
}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"events": {Name: "events", Type: schema.ArrayColumnType{
ElementType: schema.ColumnTypeString,
@@ -176,7 +177,7 @@ func (m *defaultFieldMapper) getColumn(
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{}, qbtypes.ErrColumnNotFound
return []*schema.Column{indexV3Columns["scope"]}, nil
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
@@ -261,21 +262,29 @@ func (m *defaultFieldMapper) FieldFor(
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
if key.FieldContext != telemetrytypes.FieldContextResource {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
}
oldColumn := indexV3Columns["resources_string"]
oldKeyName := fmt.Sprintf("%s['%s']", oldColumn.Name, key.Name)
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
if key.Materialized {
oldKeyName = telemetrytypes.FieldKeyToMaterializedColumnName(key)
oldKeyNameExists := telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, %s==true, %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldKeyNameExists, oldKeyName), nil
} else {
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
switch key.FieldContext {
case telemetrytypes.FieldContextScope:
scopeKey, _ := strings.CutPrefix(key.Name, "scope.") // required for support current implementation of select fields
switch scopeKey {
case "name", "version":
return fmt.Sprintf("%s.%s::String", column.Name, scopeKey), nil
default:
return fmt.Sprintf("%s.attributes.`%s`::String", column.Name, scopeKey), nil
}
case telemetrytypes.FieldContextResource:
oldColumn := indexV3Columns["resources_string"]
oldKeyName := fmt.Sprintf("%s['%s']", oldColumn.Name, key.Name)
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
if key.Materialized {
oldKeyName = telemetrytypes.FieldKeyToMaterializedColumnName(key)
oldKeyNameExists := telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, %s==true, %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldKeyNameExists, oldKeyName), nil
} else {
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
}
}
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "json column type only supported for resource and scope context, got %s", key.FieldContext.String)
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,

View File

@@ -78,6 +78,51 @@ func TestGetFieldKeyName(t *testing.T) {
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
expectedError: nil,
},
{
name: "Scope field - name (normalized)",
key: telemetrytypes.TelemetryFieldKey{
Name: "name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.name::String",
expectedError: nil,
},
{
name: "Scope field - name (un-normalized)",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.name::String",
expectedError: nil,
},
{
name: "Scope field - version (normalized)",
key: telemetrytypes.TelemetryFieldKey{
Name: "version",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.version::String",
expectedError: nil,
},
{
name: "Scope field - version (un-normalized)",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.version::String",
expectedError: nil,
},
{
name: "Scope field - custom attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "custom.attr",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.attributes.`custom.attr`::String",
expectedError: nil,
},
{
name: "Non-existent column",
key: telemetrytypes.TelemetryFieldKey{

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