mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-14 14:10:32 +01:00
Compare commits
3 Commits
platform-t
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d91a8e48c5 | ||
|
|
27ca20fe43 | ||
|
|
0fd273ebab |
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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}"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
registry = 'https://registry.npmjs.org/'
|
||||
engine-strict=true
|
||||
|
||||
public-hoist-pattern[]=@commitlint*
|
||||
public-hoist-pattern[]=commitlint
|
||||
@@ -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",
|
||||
|
||||
76
frontend/pnpm-lock.yaml
generated
76
frontend/pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])));
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CustomDomainSettings from '../CustomDomainSettings';
|
||||
|
||||
@@ -44,20 +44,18 @@ const mockHostsResponse: GetHosts200 = {
|
||||
};
|
||||
|
||||
describe('CustomDomainSettings', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
mockToastCustom.mockClear();
|
||||
});
|
||||
|
||||
it('renders active host URL in the trigger button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
// The active host is the non-default one (custom-host)
|
||||
@@ -65,11 +63,20 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('opens edit modal when clicking the edit button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /edit workspace link/i }),
|
||||
@@ -82,20 +89,28 @@ describe('CustomDomainSettings', () => {
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedBody = await req.json<Record<string, unknown>>();
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
// The input is inside the modal — find it by its role
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toStrictEqual({ name: 'myteam' });
|
||||
@@ -104,6 +119,9 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
it('shows contact support option when domain update returns 409', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(409),
|
||||
@@ -112,14 +130,18 @@ describe('CustomDomainSettings', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByRole('button', { name: /contact support/i }),
|
||||
@@ -127,14 +149,24 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows validation error when subdomain is less than 3 characters', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'ab' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ab');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/minimum 3 characters required/i),
|
||||
@@ -142,12 +174,19 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
fireEvent.click(
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
@@ -168,19 +207,26 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
it('calls toast.custom with new URL after successful domain update', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
// Verify toast.custom was called
|
||||
await waitFor(() => {
|
||||
@@ -197,6 +243,12 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
@@ -207,6 +259,12 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { IDiskType } from 'types/api/disks/getDisks';
|
||||
import {
|
||||
PayloadPropsLogs,
|
||||
@@ -109,6 +115,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 1: S3 Enabled - Only Days in Dropdown', () => {
|
||||
it('should show only Days option for S3 retention and send correct API payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -151,7 +159,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
fireEvent.click(document.body);
|
||||
|
||||
// Change S3 retention value to 5 days
|
||||
fireEvent.change(s3Input, { target: { value: '5' } });
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs row
|
||||
const saveButton = logsRow.querySelector(
|
||||
@@ -208,6 +217,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 2: S3 Disabled - Field Hidden', () => {
|
||||
it('should hide S3 retention field and send empty S3 values to API', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -234,7 +245,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const totalDropdown = logsRow.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
fireEvent.mouseDown(totalDropdown);
|
||||
await user.click(totalDropdown);
|
||||
|
||||
// Wait for dropdown options to appear
|
||||
await waitFor(() => {
|
||||
@@ -248,10 +259,11 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
opt.textContent?.includes('Days'),
|
||||
);
|
||||
expect(daysOption).toBeInTheDocument();
|
||||
fireEvent.click(daysOption as HTMLElement);
|
||||
await user.click(daysOption as HTMLElement);
|
||||
|
||||
// Now change the value
|
||||
fireEvent.change(totalInput, { target: { value: '60' } });
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const saveButton = logsRow.querySelector(
|
||||
@@ -265,14 +277,14 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
|
||||
// Click save button
|
||||
fireEvent.click(saveButton);
|
||||
await user.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
expect(okButton).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
fireEvent.click(okButton);
|
||||
await user.click(okButton);
|
||||
|
||||
// Verify API was called with empty S3 values (60 days)
|
||||
await waitFor(() => {
|
||||
@@ -321,6 +333,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 4: Save Button State with S3 Disabled', () => {
|
||||
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -351,7 +365,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
fireEvent.change(totalInput, { target: { value: '60' } });
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Button should now be enabled after change
|
||||
await waitFor(() => {
|
||||
@@ -359,7 +374,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
|
||||
// Revert to original value (30 days displays as 1 Month)
|
||||
fireEvent.change(totalInput, { target: { value: '1' } });
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '1');
|
||||
|
||||
// Button should be disabled again (back to original state)
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
|
||||
@@ -76,27 +76,32 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
|
||||
await user.click(screen.getByRole('button', { name: /all members/i }));
|
||||
|
||||
const pendingOption = await screen.findByText(/pending invites/i);
|
||||
fireEvent.click(pendingOption);
|
||||
await user.click(pendingOption);
|
||||
|
||||
await screen.findByText('charlie@signoz.io');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters members by name using the search input', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
|
||||
target: { value: 'bob' },
|
||||
});
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'bob',
|
||||
);
|
||||
|
||||
await screen.findByText('Bob Jones');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
@@ -104,25 +109,31 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when an active member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
await user.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Dave Deleted'));
|
||||
await user.click(await screen.findByText('Dave Deleted'));
|
||||
|
||||
expect(screen.queryByText('Member Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
await user.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
|
||||
@@ -117,7 +117,8 @@ describe('CreateEdit Modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Form Validation', () => {
|
||||
it('shows validation error when submitting without required fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -126,7 +127,7 @@ describe('CreateEdit Modal', () => {
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
fireEvent.click(configureButtons[0]);
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
const saveButton = await screen.findByRole('button', {
|
||||
name: /save changes/i,
|
||||
@@ -337,8 +338,11 @@ describe('CreateEdit Modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
@@ -348,7 +352,7 @@ describe('CreateEdit Modal', () => {
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
// Ungate feature flag for all tests in this file
|
||||
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
|
||||
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
@@ -29,7 +22,7 @@ const allScopeObjectsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -51,7 +44,8 @@ afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('RoleDetailsPage', () => {
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('RoleDetailsPage', () => {
|
||||
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
|
||||
setupDefaultHandlers();
|
||||
|
||||
@@ -63,16 +57,20 @@ describe('RoleDetailsPage', () => {
|
||||
screen.findByText('Role — billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Tab navigation
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('Members')).toBeInTheDocument();
|
||||
|
||||
// Role description (OverviewTab)
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Permission items derived from mocked authz relations
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read')).toBeInTheDocument();
|
||||
|
||||
// Action buttons present for custom role
|
||||
expect(
|
||||
screen.getByRole('button', { name: /edit role details/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -98,13 +96,14 @@ describe('RoleDetailsPage', () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Action buttons absent for managed role
|
||||
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /delete role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save', async () => {
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save and verify', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
let description = customRoleResponse.data.description;
|
||||
server.use(
|
||||
@@ -139,16 +138,21 @@ describe('RoleDetailsPage', () => {
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
// Open the edit modal
|
||||
await user.click(screen.getByRole('button', { name: /edit role details/i }));
|
||||
await expect(
|
||||
screen.findByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
screen.findByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Name field is disabled in edit mode (role rename is not allowed)
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
'Enter role name e.g. : Service Owner',
|
||||
);
|
||||
expect(nameInput).toBeDisabled();
|
||||
|
||||
// Update description and save
|
||||
const descField = screen.getByPlaceholderText(
|
||||
'A helpful description of the role',
|
||||
);
|
||||
@@ -164,7 +168,9 @@ describe('RoleDetailsPage', () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
screen.queryByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
@@ -213,61 +219,58 @@ describe('RoleDetailsPage', () => {
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(roleApi, 'useGetObjects')
|
||||
.mockReturnValue({ data: emptyObjectsResponse, isLoading: false } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openCreatePanel(): Promise<HTMLElement> {
|
||||
async function openCreatePanel(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
fireEvent.click(screen.getByText('Create'));
|
||||
await user.click(screen.getByText('Create'));
|
||||
await screen.findByText('Edit Create Permissions');
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'Role' });
|
||||
return panel;
|
||||
await screen.findByRole('button', { name: /dashboard/i });
|
||||
}
|
||||
|
||||
it('Save Changes is disabled until a resource scope is changed', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
await openCreatePanel(user);
|
||||
|
||||
// No change yet — config matches initial, unsavedCount = 0
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
|
||||
// Expand Dashboard and flip to All — now Save is enabled
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
screen.getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
|
||||
// check for what shown now - unsavedCount = 1
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -275,23 +278,23 @@ describe('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
await openCreatePanel(user);
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -303,9 +306,14 @@ describe('RoleDetailsPage', () => {
|
||||
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -313,28 +321,29 @@ describe('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
await openCreatePanel(user);
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
|
||||
const combobox = within(panel).getByRole('combobox');
|
||||
fireEvent.change(combobox, { target: { value: 'role-001' } });
|
||||
fireEvent.keyDown(combobox, { key: 'Enter', keyCode: 13 });
|
||||
const combobox = screen.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'dash-1');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['role-001'],
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['dash-1'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
@@ -345,13 +354,15 @@ describe('RoleDetailsPage', () => {
|
||||
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
|
||||
data: allScopeObjectsResponse,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(allScopeObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -359,24 +370,26 @@ describe('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
await openCreatePanel(user);
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
|
||||
await user.click(screen.getByText('Only selected'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
// Should delete the '*' selector and add nothing
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -385,25 +398,36 @@ describe('RoleDetailsPage', () => {
|
||||
});
|
||||
|
||||
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
await openCreatePanel(user);
|
||||
|
||||
// No unsaved changes indicator yet
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
// Change dashboard scope to "All"
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: /discard/i }));
|
||||
// Discard reverts to initial config — counter disappears, Save re-disabled
|
||||
await user.click(screen.getByRole('button', { name: /discard/i }));
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,27 +4,24 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
const rolesApiURL = 'http://localhost/api/v1/roles';
|
||||
|
||||
describe('RolesSettings', () => {
|
||||
beforeEach(() => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
render(<RolesSettings />);
|
||||
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
@@ -37,6 +34,12 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('displays roles grouped by managed and custom sections', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
@@ -65,13 +68,20 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on name', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'billing' },
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'billing');
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
@@ -82,13 +92,20 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on description', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'read-only' },
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'read-only');
|
||||
|
||||
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
|
||||
@@ -96,13 +113,20 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when search matches nothing', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'nonexistentrole' },
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'nonexistentrole');
|
||||
|
||||
await expect(
|
||||
screen.findByText('No roles match your search.'),
|
||||
@@ -159,6 +183,12 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('renders descriptions for all roles', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
@@ -123,6 +123,8 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -131,16 +133,18 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
fireEvent.click(activeOption);
|
||||
await user.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search by name filters accounts in real-time', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -149,9 +153,10 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
|
||||
target: { value: 'legacy' },
|
||||
});
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'legacy',
|
||||
);
|
||||
|
||||
await screen.findByText('Legacy Bot');
|
||||
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
|
||||
@@ -159,13 +164,15 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('clicking a row opens the drawer with account details visible', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
}),
|
||||
@@ -177,6 +184,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('saving changes in the drawer refetches the list', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const listRefetchSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
@@ -198,14 +206,15 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
await screen.findByText('CI Bot');
|
||||
listRefetchSpy.mockClear();
|
||||
|
||||
fireEvent.click(
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
fireEvent.change(nameInput, { target: { value: 'CI Bot Updated' } });
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
await waitFor(() => {
|
||||
@@ -214,6 +223,8 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -222,7 +233,9 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
);
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import {
|
||||
TooltipRoot,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
@@ -497,7 +497,7 @@ function SpanDetailsPanel({
|
||||
key: 'dock-toggle',
|
||||
component: (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -515,7 +515,7 @@ function SpanDetailsPanel({
|
||||
<TooltipContent className="dock-toggle-tooltip">
|
||||
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
TooltipRoot,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
@@ -149,7 +149,7 @@ export function SpanHoverCard({
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot open={hoverCardData !== null} onOpenChange={onOpenChange}>
|
||||
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="span-hover-card-anchor"
|
||||
@@ -168,7 +168,7 @@ export function SpanHoverCard({
|
||||
>
|
||||
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TooltipRoot,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
@@ -145,7 +145,7 @@ function TraceDetailsHeader({
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -158,7 +158,7 @@ function TraceDetailsHeader({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Analytics</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TooltipRoot,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
@@ -22,7 +22,7 @@ export default function SpanLineActionButtons({
|
||||
return (
|
||||
<div className="span-line-action-buttons">
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -37,7 +37,7 @@ export default function SpanLineActionButtons({
|
||||
<TooltipContent className="span-line-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ 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,
|
||||
@@ -269,7 +269,7 @@ function Filters({
|
||||
<>
|
||||
{isFetching && <Loader className="animate-spin" />}
|
||||
{error && (
|
||||
<TooltipRoot>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="filter-status filter-status--error">
|
||||
<Info />
|
||||
@@ -279,7 +279,7 @@ function Filters({
|
||||
<TooltipContent>
|
||||
{(error as AxiosError)?.message || 'Something went wrong'}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!error && noData && (
|
||||
<Typography.Text className="filter-status">
|
||||
@@ -304,7 +304,7 @@ function Filters({
|
||||
<TooltipProvider>
|
||||
<div className="trace-v3-filter-row collapsed">
|
||||
{expression ? (
|
||||
<TooltipRoot>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<div className="filter-pill-popover">
|
||||
@@ -328,7 +328,7 @@ function Filters({
|
||||
<div className="filter-pill-popover__expression">{expression}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
) : (
|
||||
pill
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TooltipRoot,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
@@ -112,9 +112,9 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot
|
||||
<Tooltip
|
||||
open
|
||||
onOpenChange={(open: boolean): void => {
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
@@ -129,7 +129,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
attributeMap={event.attributeMap || {}}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
@@ -329,7 +329,7 @@ const SpanOverview = memo(function SpanOverview({
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<TooltipRoot>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -344,8 +344,8 @@ const SpanOverview = memo(function SpanOverview({
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
<TooltipRoot>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -360,7 +360,7 @@ const SpanOverview = memo(function SpanOverview({
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Add to Trace Funnel
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -200,7 +200,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore),
|
||||
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateMetaresourcesTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -47,42 +47,6 @@ func NewTuplesFromTransactions(transactions []*Transaction, subject string, orgI
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
// NewTuplesFromTransactionsWithCorrelations converts transactions to tuples for BatchCheck,
|
||||
// and for each transaction whose selector is not already a wildcard, generates an additional
|
||||
// tuple with the wildcard selector. This ensures that permissions granted via wildcard
|
||||
// selectors (e.g., dashboard:*) are checked alongside exact selectors (e.g., dashboard:abc-123).
|
||||
//
|
||||
// Returns:
|
||||
// - tuples: all tuples to check (exact + correlated), keyed by transaction ID or generated correlation ID
|
||||
// - correlations: maps transaction ID to a slice of correlation IDs for the additional tuples
|
||||
func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subject string, orgID valuer.UUID) (tuples map[string]*openfgav1.TupleKey, correlations map[string][]string, err error) {
|
||||
tuples = make(map[string]*openfgav1.TupleKey)
|
||||
correlations = make(map[string][]string)
|
||||
|
||||
for _, txn := range transactions {
|
||||
resource, err := coretypes.NewResourceFromTypeAndKind(txn.Object.Resource.Type, txn.Object.Resource.Kind)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
txnID := txn.ID.StringValue()
|
||||
|
||||
txnTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{txn.Object.Selector}, orgID)
|
||||
tuples[txnID] = txnTuples[0]
|
||||
|
||||
if txn.Object.Selector.String() != coretypes.WildCardSelectorString {
|
||||
wildcardSelector := txn.Object.Resource.Type.MustSelector(coretypes.WildCardSelectorString)
|
||||
wildcardTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{wildcardSelector}, orgID)
|
||||
|
||||
correlationID := valuer.GenerateUUID().StringValue()
|
||||
tuples[correlationID] = wildcardTuples[0]
|
||||
correlations[txnID] = append(correlations[txnID], correlationID)
|
||||
}
|
||||
}
|
||||
|
||||
return tuples, correlations, nil
|
||||
}
|
||||
|
||||
// NewTuplesFromTransactionsWithManagedRoles converts transactions to tuples for BatchCheck.
|
||||
// Direct role-assignment transactions (TypeRole + VerbAssignee) produce one tuple keyed by txn ID.
|
||||
// Other transactions are expanded via managedRolesByTransaction into role-assignee checks, keyed by "txnID:roleName".
|
||||
|
||||
@@ -18,49 +18,46 @@ const (
|
||||
|
||||
var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
SigNozAdminRoleName: {
|
||||
// role attach/detach — admin can attach/detach role assignments
|
||||
// role attach — admin can attach/detach role assignments
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
// user attach/detach — admin can attach/detach roles to any user
|
||||
// user attach — admin can attach roles to any user
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
// serviceaccount attach/detach — admin can attach/detach roles to any SA
|
||||
// serviceaccount attach — admin can attach roles to any SA
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
// auth-domain — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
// cloud-integration — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// integration — viewer/editor/admin (install/uninstall via ViewAccess)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
// factor-api-key — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindFactorAPIKey}, WildCardSelectorString)},
|
||||
// factor-password — admin can issue and inspect reset tokens; users change their own password via OpenAccess
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindFactorPassword}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindFactorPassword}, WildCardSelectorString)},
|
||||
// license — admin only.
|
||||
// Uniform LCRUD shape; actual ee routes are POST /api/v3/licenses (create
|
||||
// = Activate), PUT /api/v3/licenses (update = Refresh), GET
|
||||
@@ -70,8 +67,8 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindLicense}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindLicense}, WildCardSelectorString)},
|
||||
// subscription — admin only.
|
||||
// Uniform LCRUD shape; actual ee routes are POST /api/v1/checkout
|
||||
// (create), POST /api/v1/portal (update — opens Stripe portal), GET
|
||||
@@ -81,121 +78,123 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
// organization — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeOrganization, Kind: KindOrganization}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeOrganization, Kind: KindOrganization}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindOrganization}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindOrganization}, WildCardSelectorString)},
|
||||
// org-preference — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
// public-dashboard — admin manages, anonymous reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPublicDashboard}, WildCardSelectorString)},
|
||||
// role — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeRole, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRole}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRole}, WildCardSelectorString)},
|
||||
// serviceaccount — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeServiceAccount, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindServiceAccount}, WildCardSelectorString)},
|
||||
// session — admin can revoke and list
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSession}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSession}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSession}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSession}, WildCardSelectorString)},
|
||||
// user — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeUser, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindUser}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindUser}, WildCardSelectorString)},
|
||||
// dashboard — full CRUD (also held by editor)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
// pipeline — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
// planned-maintenance — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
// trace-funnel — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
// ingestion-key — editor+admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
// ingestion-limit — editor+admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
// notification-channel — admin writes, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
// route-policy — admin writes, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
// apdex-setting — admin updates, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
// quick-filter — admin updates, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
// ttl-setting — admin updates, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
// user-preference — every authenticated user can read+update their own
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
// telemetry — read on each signal (logs/traces/metrics); schema permits read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindLogs}, WildCardSelectorString)},
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindTraces}, WildCardSelectorString)},
|
||||
@@ -208,86 +207,86 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
// logs-field — editor+admin update (POST overwrites field config), viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
// traces-field — editor+admin update, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozEditorRoleName: {
|
||||
// dashboard — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
// pipeline — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
// planned-maintenance — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
// trace-funnel — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
// integration — viewer/editor/admin
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
// ingestion-key — editor+admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionKey}, WildCardSelectorString)},
|
||||
// ingestion-limit — editor+admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIngestionLimit}, WildCardSelectorString)},
|
||||
// notification-channel — read only (admin writes)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
// route-policy — read only (admin writes)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
// apdex-setting — read only (admin updates)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
// quick-filter — read only (admin updates)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
// ttl-setting — read only (admin updates)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
// user-preference — every authenticated user can read+update their own
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
// telemetry — read on each signal
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindLogs}, WildCardSelectorString)},
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindTraces}, WildCardSelectorString)},
|
||||
@@ -295,66 +294,66 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
// logs-field — editor reads+updates
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
// traces-field — editor reads+updates
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozViewerRoleName: {
|
||||
// dashboard — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
// pipeline — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPipeline}, WildCardSelectorString)},
|
||||
// planned-maintenance — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
// trace-funnel — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTraceFunnel}, WildCardSelectorString)},
|
||||
// integration — viewer/editor/admin (install/uninstall via ViewAccess)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
// notification-channel — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
// route-policy — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
// apdex-setting — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
// quick-filter — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindQuickFilter}, WildCardSelectorString)},
|
||||
// ttl-setting — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
// user-preference — every authenticated user can read+update their own
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
// telemetry — read on each signal
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindLogs}, WildCardSelectorString)},
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindTraces}, WildCardSelectorString)},
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindMetrics}, WildCardSelectorString)},
|
||||
// logs-field — viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindLogsField}, WildCardSelectorString)},
|
||||
// traces-field — viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozAnonymousRoleName: {
|
||||
// public-dashboard — anonymous read
|
||||
|
||||
@@ -6,76 +6,138 @@ var Resources = []Resource{
|
||||
ResourceRole,
|
||||
ResourceServiceAccount,
|
||||
ResourceUser,
|
||||
ResourceMetaResourcesRole,
|
||||
ResourceMetaResourcesOrganization,
|
||||
ResourceMetaResourcesServiceAccount,
|
||||
ResourceMetaResourcesUser,
|
||||
ResourceMetaResourceNotificationChannel,
|
||||
ResourceMetaResourcesNotificationChannel,
|
||||
ResourceMetaResourceRoutePolicy,
|
||||
ResourceMetaResourcesRoutePolicy,
|
||||
ResourceMetaResourceApdexSetting,
|
||||
ResourceMetaResourcesApdexSetting,
|
||||
ResourceMetaResourceAuthDomain,
|
||||
ResourceMetaResourcesAuthDomain,
|
||||
ResourceMetaResourceSession,
|
||||
ResourceMetaResourcesSession,
|
||||
ResourceMetaResourceCloudIntegration,
|
||||
ResourceMetaResourcesCloudIntegration,
|
||||
ResourceMetaResourceCloudIntegrationService,
|
||||
ResourceMetaResourcesCloudIntegrationService,
|
||||
ResourceMetaResourceIntegration,
|
||||
ResourceMetaResourcesIntegration,
|
||||
ResourceMetaResourceDashboard,
|
||||
ResourceMetaResourcesDashboard,
|
||||
ResourceMetaResourcePublicDashboard,
|
||||
ResourceMetaResourcesPublicDashboard,
|
||||
ResourceMetaResourceIngestionKey,
|
||||
ResourceMetaResourcesIngestionKey,
|
||||
ResourceMetaResourceIngestionLimit,
|
||||
ResourceMetaResourcesIngestionLimit,
|
||||
ResourceMetaResourcePipeline,
|
||||
ResourceMetaResourcesPipeline,
|
||||
ResourceMetaResourceUserPreference,
|
||||
ResourceMetaResourcesUserPreference,
|
||||
ResourceMetaResourceOrgPreference,
|
||||
ResourceMetaResourcesOrgPreference,
|
||||
ResourceMetaResourceQuickFilter,
|
||||
ResourceMetaResourcesQuickFilter,
|
||||
ResourceMetaResourceTTLSetting,
|
||||
ResourceMetaResourcesTTLSetting,
|
||||
ResourceMetaResourceRule,
|
||||
ResourceMetaResourcesRule,
|
||||
ResourceMetaResourcePlannedMaintenance,
|
||||
ResourceMetaResourcesPlannedMaintenance,
|
||||
ResourceMetaResourceSavedView,
|
||||
ResourceMetaResourcesSavedView,
|
||||
ResourceMetaResourceTraceFunnel,
|
||||
ResourceMetaResourcesTraceFunnel,
|
||||
ResourceMetaResourceFactorPassword,
|
||||
ResourceMetaResourcesFactorPassword,
|
||||
ResourceMetaResourceFactorAPIKey,
|
||||
ResourceMetaResourcesFactorAPIKey,
|
||||
ResourceMetaResourceLicense,
|
||||
ResourceMetaResourcesLicense,
|
||||
ResourceMetaResourceSubscription,
|
||||
ResourceMetaResourcesSubscription,
|
||||
ResourceTelemetryResourceLogs,
|
||||
ResourceTelemetryResourceTraces,
|
||||
ResourceTelemetryResourceMetrics,
|
||||
ResourceTelemetryResourceAuditLogs,
|
||||
ResourceTelemetryResourceMeterMetrics,
|
||||
ResourceMetaResourceLogsField,
|
||||
ResourceMetaResourcesLogsField,
|
||||
ResourceMetaResourceTracesField,
|
||||
ResourceMetaResourcesTracesField,
|
||||
}
|
||||
|
||||
var (
|
||||
ResourceAnonymous Resource = NewResourceAnonymous()
|
||||
ResourceOrganization = NewResourceOrganization()
|
||||
ResourceRole = NewResourceRole()
|
||||
ResourceServiceAccount = NewResourceServiceAccount()
|
||||
ResourceUser = NewResourceUser()
|
||||
ResourceMetaResourceNotificationChannel = NewResourceMetaResource(KindNotificationChannel)
|
||||
ResourceMetaResourceRoutePolicy = NewResourceMetaResource(KindRoutePolicy)
|
||||
ResourceMetaResourceApdexSetting = NewResourceMetaResource(KindApdexSetting)
|
||||
ResourceMetaResourceAuthDomain = NewResourceMetaResource(KindAuthDomain)
|
||||
ResourceMetaResourceSession = NewResourceMetaResource(KindSession)
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
|
||||
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard)
|
||||
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
|
||||
ResourceMetaResourceIngestionKey = NewResourceMetaResource(KindIngestionKey)
|
||||
ResourceMetaResourceIngestionLimit = NewResourceMetaResource(KindIngestionLimit)
|
||||
ResourceMetaResourcePipeline = NewResourceMetaResource(KindPipeline)
|
||||
ResourceMetaResourceUserPreference = NewResourceMetaResource(KindUserPreference)
|
||||
ResourceMetaResourceOrgPreference = NewResourceMetaResource(KindOrgPreference)
|
||||
ResourceMetaResourceQuickFilter = NewResourceMetaResource(KindQuickFilter)
|
||||
ResourceMetaResourceTTLSetting = NewResourceMetaResource(KindTTLSetting)
|
||||
ResourceMetaResourceRule = NewResourceMetaResource(KindRule)
|
||||
ResourceMetaResourcePlannedMaintenance = NewResourceMetaResource(KindPlannedMaintenance)
|
||||
ResourceMetaResourceSavedView = NewResourceMetaResource(KindSavedView)
|
||||
ResourceMetaResourceTraceFunnel = NewResourceMetaResource(KindTraceFunnel)
|
||||
ResourceMetaResourceFactorPassword = NewResourceMetaResource(KindFactorPassword)
|
||||
ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey)
|
||||
ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense)
|
||||
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription)
|
||||
ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs)
|
||||
ResourceTelemetryResourceTraces = NewResourceTelemetryResource(KindTraces)
|
||||
ResourceTelemetryResourceMetrics = NewResourceTelemetryResource(KindMetrics)
|
||||
ResourceTelemetryResourceAuditLogs = NewResourceTelemetryResource(KindAuditLogs)
|
||||
ResourceTelemetryResourceMeterMetrics = NewResourceTelemetryResource(KindMeterMetrics)
|
||||
ResourceMetaResourceLogsField = NewResourceMetaResource(KindLogsField)
|
||||
ResourceMetaResourceTracesField = NewResourceMetaResource(KindTracesField)
|
||||
ResourceAnonymous Resource = NewResourceAnonymous()
|
||||
ResourceOrganization = NewResourceOrganization()
|
||||
ResourceRole = NewResourceRole()
|
||||
ResourceServiceAccount = NewResourceServiceAccount()
|
||||
ResourceUser = NewResourceUser()
|
||||
ResourceMetaResourcesRole = NewResourceMetaResources(KindRole)
|
||||
ResourceMetaResourcesOrganization = NewResourceMetaResources(KindOrganization)
|
||||
ResourceMetaResourcesServiceAccount = NewResourceMetaResources(KindServiceAccount)
|
||||
ResourceMetaResourcesUser = NewResourceMetaResources(KindUser)
|
||||
ResourceMetaResourceNotificationChannel = NewResourceMetaResource(KindNotificationChannel)
|
||||
ResourceMetaResourcesNotificationChannel = NewResourceMetaResources(KindNotificationChannel)
|
||||
ResourceMetaResourceRoutePolicy = NewResourceMetaResource(KindRoutePolicy)
|
||||
ResourceMetaResourcesRoutePolicy = NewResourceMetaResources(KindRoutePolicy)
|
||||
ResourceMetaResourceApdexSetting = NewResourceMetaResource(KindApdexSetting)
|
||||
ResourceMetaResourcesApdexSetting = NewResourceMetaResources(KindApdexSetting)
|
||||
ResourceMetaResourceAuthDomain = NewResourceMetaResource(KindAuthDomain)
|
||||
ResourceMetaResourcesAuthDomain = NewResourceMetaResources(KindAuthDomain)
|
||||
ResourceMetaResourceSession = NewResourceMetaResource(KindSession)
|
||||
ResourceMetaResourcesSession = NewResourceMetaResources(KindSession)
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
|
||||
ResourceMetaResourcesCloudIntegration = NewResourceMetaResources(KindCloudIntegration)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
|
||||
ResourceMetaResourcesCloudIntegrationService = NewResourceMetaResources(KindCloudIntegrationService)
|
||||
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
|
||||
ResourceMetaResourcesIntegration = NewResourceMetaResources(KindIntegration)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard)
|
||||
ResourceMetaResourcesDashboard = NewResourceMetaResources(KindDashboard)
|
||||
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
|
||||
ResourceMetaResourcesPublicDashboard = NewResourceMetaResources(KindPublicDashboard)
|
||||
ResourceMetaResourceIngestionKey = NewResourceMetaResource(KindIngestionKey)
|
||||
ResourceMetaResourcesIngestionKey = NewResourceMetaResources(KindIngestionKey)
|
||||
ResourceMetaResourceIngestionLimit = NewResourceMetaResource(KindIngestionLimit)
|
||||
ResourceMetaResourcesIngestionLimit = NewResourceMetaResources(KindIngestionLimit)
|
||||
ResourceMetaResourcePipeline = NewResourceMetaResource(KindPipeline)
|
||||
ResourceMetaResourcesPipeline = NewResourceMetaResources(KindPipeline)
|
||||
ResourceMetaResourceUserPreference = NewResourceMetaResource(KindUserPreference)
|
||||
ResourceMetaResourcesUserPreference = NewResourceMetaResources(KindUserPreference)
|
||||
ResourceMetaResourceOrgPreference = NewResourceMetaResource(KindOrgPreference)
|
||||
ResourceMetaResourcesOrgPreference = NewResourceMetaResources(KindOrgPreference)
|
||||
ResourceMetaResourceQuickFilter = NewResourceMetaResource(KindQuickFilter)
|
||||
ResourceMetaResourcesQuickFilter = NewResourceMetaResources(KindQuickFilter)
|
||||
ResourceMetaResourceTTLSetting = NewResourceMetaResource(KindTTLSetting)
|
||||
ResourceMetaResourcesTTLSetting = NewResourceMetaResources(KindTTLSetting)
|
||||
ResourceMetaResourceRule = NewResourceMetaResource(KindRule)
|
||||
ResourceMetaResourcesRule = NewResourceMetaResources(KindRule)
|
||||
ResourceMetaResourcePlannedMaintenance = NewResourceMetaResource(KindPlannedMaintenance)
|
||||
ResourceMetaResourcesPlannedMaintenance = NewResourceMetaResources(KindPlannedMaintenance)
|
||||
ResourceMetaResourceSavedView = NewResourceMetaResource(KindSavedView)
|
||||
ResourceMetaResourcesSavedView = NewResourceMetaResources(KindSavedView)
|
||||
ResourceMetaResourceTraceFunnel = NewResourceMetaResource(KindTraceFunnel)
|
||||
ResourceMetaResourcesTraceFunnel = NewResourceMetaResources(KindTraceFunnel)
|
||||
ResourceMetaResourceFactorPassword = NewResourceMetaResource(KindFactorPassword)
|
||||
ResourceMetaResourcesFactorPassword = NewResourceMetaResources(KindFactorPassword)
|
||||
ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey)
|
||||
ResourceMetaResourcesFactorAPIKey = NewResourceMetaResources(KindFactorAPIKey)
|
||||
ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense)
|
||||
ResourceMetaResourcesLicense = NewResourceMetaResources(KindLicense)
|
||||
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription)
|
||||
ResourceMetaResourcesSubscription = NewResourceMetaResources(KindSubscription)
|
||||
ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs)
|
||||
ResourceTelemetryResourceTraces = NewResourceTelemetryResource(KindTraces)
|
||||
ResourceTelemetryResourceMetrics = NewResourceTelemetryResource(KindMetrics)
|
||||
ResourceTelemetryResourceAuditLogs = NewResourceTelemetryResource(KindAuditLogs)
|
||||
ResourceTelemetryResourceMeterMetrics = NewResourceTelemetryResource(KindMeterMetrics)
|
||||
ResourceMetaResourceLogsField = NewResourceMetaResource(KindLogsField)
|
||||
ResourceMetaResourcesLogsField = NewResourceMetaResources(KindLogsField)
|
||||
ResourceMetaResourceTracesField = NewResourceMetaResource(KindTracesField)
|
||||
ResourceMetaResourcesTracesField = NewResourceMetaResources(KindTracesField)
|
||||
)
|
||||
|
||||
@@ -13,15 +13,17 @@ var Types = []Type{
|
||||
TypeRole,
|
||||
TypeOrganization,
|
||||
TypeMetaResource,
|
||||
TypeMetaResources,
|
||||
TypeTelemetryResource,
|
||||
}
|
||||
|
||||
var (
|
||||
TypeUser = Type{valuer.NewString("user"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeServiceAccount = Type{valuer.NewString("serviceaccount"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeUser = Type{valuer.NewString("user"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbAttach, VerbRead, VerbUpdate, VerbDelete}}
|
||||
TypeServiceAccount = Type{valuer.NewString("serviceaccount"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbAttach, VerbRead, VerbUpdate, VerbDelete}}
|
||||
TypeAnonymous = Type{valuer.NewString("anonymous"), regexp.MustCompile(`^\*$`), []Verb{}}
|
||||
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbAttach, VerbRead, VerbUpdate, VerbDelete}}
|
||||
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate, VerbDelete}}
|
||||
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate, VerbDelete}}
|
||||
TypeMetaResources = Type{valuer.NewString("metaresources"), regexp.MustCompile(`^\*$`), []Verb{VerbCreate, VerbList}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^\*$`), []Verb{VerbRead}}
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ var Verbs = []Verb{
|
||||
VerbList,
|
||||
VerbAssignee,
|
||||
VerbAttach,
|
||||
VerbDetach,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -21,5 +20,4 @@ var (
|
||||
VerbList = Verb{valuer.NewString("list"), "listed"}
|
||||
VerbAssignee = Verb{valuer.NewString("assignee"), "assigned"}
|
||||
VerbAttach = Verb{valuer.NewString("attach"), "attached"}
|
||||
VerbDetach = Verb{valuer.NewString("detach"), "detached"}
|
||||
)
|
||||
|
||||
34
pkg/types/coretypes/resource_metaresources.go
Normal file
34
pkg/types/coretypes/resource_metaresources.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type resourceMetaResources struct {
|
||||
kind Kind
|
||||
}
|
||||
|
||||
func NewResourceMetaResources(kind Kind) Resource {
|
||||
return &resourceMetaResources{kind: kind}
|
||||
}
|
||||
|
||||
func (*resourceMetaResources) Type() Type {
|
||||
return TypeMetaResources
|
||||
}
|
||||
|
||||
func (resourceMetaResources *resourceMetaResources) Kind() Kind {
|
||||
return resourceMetaResources.kind
|
||||
}
|
||||
|
||||
// example: metaresources:organization/0199c47d-f61b-7833-bc5f-c0730f12f046/dashboards
|
||||
func (resourceMetaResources *resourceMetaResources) Prefix(orgID valuer.UUID) string {
|
||||
return resourceMetaResources.Type().StringValue() + ":" + "organization" + "/" + orgID.StringValue() + "/" + resourceMetaResources.Kind().String()
|
||||
}
|
||||
|
||||
func (resourceMetaResources *resourceMetaResources) Object(orgID valuer.UUID, selector string) string {
|
||||
return resourceMetaResources.Prefix(orgID) + "/" + selector
|
||||
}
|
||||
|
||||
func (resourceMetaResources *resourceMetaResources) Scope(verb Verb) string {
|
||||
return resourceMetaResources.Kind().String() + ":" + verb.StringValue()
|
||||
}
|
||||
@@ -33,8 +33,8 @@ func NewType(input string) (Type, error) {
|
||||
return TypeOrganization, nil
|
||||
case "metaresource":
|
||||
return TypeMetaResource, nil
|
||||
case "telemetryresource":
|
||||
return TypeTelemetryResource, nil
|
||||
case "metaresources":
|
||||
return TypeMetaResources, nil
|
||||
default:
|
||||
return Type{}, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidType, "invalid type: %s", input)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (typed Type) Enum() []any {
|
||||
TypeRole,
|
||||
TypeOrganization,
|
||||
TypeMetaResource,
|
||||
TypeTelemetryResource,
|
||||
TypeMetaResources,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ func NewVerb(verb string) (Verb, error) {
|
||||
return VerbAssignee, nil
|
||||
case "attach":
|
||||
return VerbAttach, nil
|
||||
case "detach":
|
||||
return VerbDetach, nil
|
||||
default:
|
||||
return Verb{}, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidVerb, "verb %s is invalid, valid verbs are: %s", verb, Verb{}.Enum())
|
||||
}
|
||||
@@ -46,7 +44,6 @@ func (Verb) Enum() []any {
|
||||
VerbList,
|
||||
VerbAssignee,
|
||||
VerbAttach,
|
||||
VerbDetach,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type Jobs struct {
|
||||
Type ResponseType `json:"type" required:"true"`
|
||||
Records []JobRecord `json:"records" required:"true"`
|
||||
Total int `json:"total" required:"true"`
|
||||
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
|
||||
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
|
||||
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type JobRecord struct {
|
||||
JobName string `json:"jobName" required:"true"`
|
||||
JobCPU float64 `json:"jobCPU" required:"true"`
|
||||
JobCPURequest float64 `json:"jobCPURequest" required:"true"`
|
||||
JobCPULimit float64 `json:"jobCPULimit" required:"true"`
|
||||
JobMemory float64 `json:"jobMemory" required:"true"`
|
||||
JobMemoryRequest float64 `json:"jobMemoryRequest" required:"true"`
|
||||
JobMemoryLimit float64 `json:"jobMemoryLimit" required:"true"`
|
||||
DesiredSuccessfulPods int `json:"desiredSuccessfulPods" required:"true"`
|
||||
ActivePods int `json:"activePods" required:"true"`
|
||||
FailedPods int `json:"failedPods" required:"true"`
|
||||
SuccessfulPods int `json:"successfulPods" required:"true"`
|
||||
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
|
||||
Meta map[string]string `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
// PostableJobs is the request body for the v2 jobs list API.
|
||||
type PostableJobs struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableJobs contains acceptable values.
|
||||
func (req *PostableJobs) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.Start <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid start time %d: start must be greater than 0",
|
||||
req.Start,
|
||||
)
|
||||
}
|
||||
|
||||
if req.End <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid end time %d: end must be greater than 0",
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Start >= req.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid time range: start (%d) must be less than end (%d)",
|
||||
req.Start,
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Limit < 1 || req.Limit > 5000 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
|
||||
}
|
||||
|
||||
if req.Offset < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(JobsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
}
|
||||
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates input immediately after decoding.
|
||||
func (req *PostableJobs) UnmarshalJSON(data []byte) error {
|
||||
type raw PostableJobs
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostableJobs(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
const (
|
||||
JobsOrderByCPU = "cpu"
|
||||
JobsOrderByCPURequest = "cpu_request"
|
||||
JobsOrderByCPULimit = "cpu_limit"
|
||||
JobsOrderByMemory = "memory"
|
||||
JobsOrderByMemoryRequest = "memory_request"
|
||||
JobsOrderByMemoryLimit = "memory_limit"
|
||||
JobsOrderByDesiredSuccessfulPods = "desired_successful_pods"
|
||||
JobsOrderByActivePods = "active_pods"
|
||||
JobsOrderByFailedPods = "failed_pods"
|
||||
JobsOrderBySuccessfulPods = "successful_pods"
|
||||
)
|
||||
|
||||
var JobsValidOrderByKeys = []string{
|
||||
JobsOrderByCPU,
|
||||
JobsOrderByCPURequest,
|
||||
JobsOrderByCPULimit,
|
||||
JobsOrderByMemory,
|
||||
JobsOrderByMemoryRequest,
|
||||
JobsOrderByMemoryLimit,
|
||||
JobsOrderByDesiredSuccessfulPods,
|
||||
JobsOrderByActivePods,
|
||||
JobsOrderByFailedPods,
|
||||
JobsOrderBySuccessfulPods,
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostableJobs_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *PostableJobs
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time zero",
|
||||
req: &PostableJobs{
|
||||
Start: 0,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time negative",
|
||||
req: &PostableJobs{
|
||||
Start: -1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "end time zero",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 0,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time greater than end time",
|
||||
req: &PostableJobs{
|
||||
Start: 2000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time equal to end time",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit zero",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 0,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit negative",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: -10,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit exceeds max",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 5001,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "offset negative",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: -5,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy nil is valid",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu and direction asc",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key memory_limit and direction desc",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderByMemoryLimit,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key desired_successful_pods and direction desc",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderByDesiredSuccessfulPods,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key active_pods and direction asc",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderByActivePods,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key failed_pods",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderByFailedPods,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key successful_pods",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderBySuccessfulPods,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with restarts key is rejected",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "restarts",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with invalid key",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "unknown",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key but invalid direction",
|
||||
req: &PostableJobs{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: JobsOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.req.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
engine-strict=true
|
||||
@@ -1,8 +1,10 @@
|
||||
import path from 'path';
|
||||
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
|
||||
import variablesTemplate from '../testdata/variables-dashboard.json';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -83,6 +85,211 @@ export async function createDashboardViaApi(
|
||||
return postDashboard(page, { title, uploadedGrafana: false });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generic helper: POST a dashboard with the given title, then PUT the full
|
||||
* `data` payload (variables / widgets / layout / version) at
|
||||
* `/dashboards/<id>`. The two-step dance is required because POST silently
|
||||
* drops everything except `{title, uploadedGrafana, version}` — the SigNoz UI
|
||||
* itself uses the same pattern.
|
||||
*/
|
||||
async function loadDashboardFromTemplate(
|
||||
page: Page,
|
||||
title: string,
|
||||
template: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const id = await postDashboard(page, { title, uploadedGrafana: false });
|
||||
const token = await authToken(page);
|
||||
const putRes = await page.request.put(`/api/v1/dashboards/${id}`, {
|
||||
data: { ...template, title },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!putRes.ok()) {
|
||||
throw new Error(
|
||||
`PUT /dashboards/${id} ${putRes.status()}: ${await putRes.text()}`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a dashboard exercising every variable type (TEXTBOX × 2, CUSTOM × 3,
|
||||
* QUERY × 2, DYNAMIC × 1) via the JSON fixture under
|
||||
* `tests/e2e/testdata/variables-dashboard.json`. Used by Group 3
|
||||
* (detail-variables) and Group 9 (detail-configure "lists existing
|
||||
* variables") tests. URL state keys variables by `name`, not `id`, so the
|
||||
* assertions look up `tb_env` / `cu_env_all` / etc. directly.
|
||||
*/
|
||||
export async function createVariablesDashboardViaApi(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
return loadDashboardFromTemplate(
|
||||
page,
|
||||
title,
|
||||
variablesTemplate as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed APM Metrics directly via the API — much faster than driving the
|
||||
* Import-JSON UI flow. Use this for any test that just needs APM Metrics on
|
||||
* the canvas; reserve `importApmMetricsDashboardViaUI` for tests that
|
||||
* actually exercise the import flow itself.
|
||||
*/
|
||||
export async function createApmMetricsDashboardViaApi(
|
||||
page: Page,
|
||||
): Promise<string> {
|
||||
return loadDashboardFromTemplate(
|
||||
page,
|
||||
APM_METRICS_TITLE,
|
||||
apmMetricsTemplate as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a single-panel "E2E Metric RPS" dashboard that queries the
|
||||
* `signoz_e2e_metric` counter without any variable substitution. Pair with
|
||||
* `seedMetricsViaSeeder` to populate the metric, then assert chart-data
|
||||
* rendering. Title is fixed by the JSON fixture.
|
||||
*/
|
||||
export async function createChartDataDashboardViaApi(
|
||||
page: Page,
|
||||
): Promise<string> {
|
||||
return loadDashboardFromTemplate(
|
||||
page,
|
||||
(chartDataTemplate as { title: string }).title,
|
||||
chartDataTemplate as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Seeder API ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// The pytest harness brings up an HTTP seeder container exposing
|
||||
// POST/DELETE on /telemetry/{traces,logs,metrics}. Its URL is written to
|
||||
// `tests/e2e/.env.local` as `SIGNOZ_E2E_SEEDER_URL` and read here from the
|
||||
// process environment.
|
||||
|
||||
/** Minimal shape the seeder accepts for a single metric sample. */
|
||||
export interface SeederMetric {
|
||||
metric_name: string;
|
||||
labels: Record<string, string>;
|
||||
timestamp: string;
|
||||
value: number;
|
||||
temporality?: 'Cumulative' | 'Delta' | 'Unspecified';
|
||||
type_?: 'Sum' | 'Gauge' | 'Histogram' | 'Summary';
|
||||
is_monotonic?: boolean;
|
||||
description?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
function seederUrl(): string {
|
||||
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a batch of metrics into the seeder. The seeder writes them directly
|
||||
* into ClickHouse, bypassing the OTLP collector. Use this for tests that need
|
||||
* panel queries to return non-empty results.
|
||||
*/
|
||||
export async function seedMetricsViaSeeder(
|
||||
page: Page,
|
||||
metrics: SeederMetric[],
|
||||
): Promise<void> {
|
||||
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
|
||||
data: metrics,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate the metrics tables in ClickHouse via the seeder. Use in
|
||||
* `afterAll` for tests that mutate global telemetry state — the bootstrap
|
||||
* stack is shared across specs, so leftover seeded rows could affect
|
||||
* neighbouring suites.
|
||||
*/
|
||||
export async function clearMetricsViaSeeder(page: Page): Promise<void> {
|
||||
await page.request.delete(`${seederUrl()}/telemetry/metrics`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for every variable in the persisted dashboard JSON to have a
|
||||
* "resolved" state — `selectedValue` populated, or `allSelected: true` for
|
||||
* showALLOption variables. This is the seam tests should cross before
|
||||
* acting: if a variable has a default in the seed, it's resolved immediately;
|
||||
* if it has no default (QUERY / DYNAMIC depending on backend resolution), the
|
||||
* UI's variable-select widget queries the backend, then writes the resolved
|
||||
* value back into the dashboard's variables map. Tests that share a dashboard
|
||||
* via `mode: 'serial'` must call this between tests so they don't race
|
||||
* against an in-flight resolve.
|
||||
*
|
||||
* Variables listed in `skipNames` are exempt — typically those that depend on
|
||||
* seeded telemetry the bootstrap stack does not produce (Dynamic; cascading
|
||||
* Query against an unresolved parent). Pass them so the wait does not block
|
||||
* indefinitely on values that can never appear.
|
||||
*/
|
||||
export async function awaitVariablesResolved(
|
||||
page: Page,
|
||||
dashboardId: string,
|
||||
options?: { skipNames?: string[]; timeout?: number },
|
||||
): Promise<void> {
|
||||
const skip = new Set(options?.skipNames ?? []);
|
||||
const timeout = options?.timeout ?? 15_000;
|
||||
const token = await authToken(page);
|
||||
|
||||
const isResolved = (v: Record<string, unknown>): boolean => {
|
||||
if (skip.has(String(v.name))) {
|
||||
return true;
|
||||
}
|
||||
if (v.allSelected === true) {
|
||||
return true;
|
||||
}
|
||||
const sv = v.selectedValue;
|
||||
if (sv === undefined || sv === null) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(sv)) {
|
||||
return sv.length > 0;
|
||||
}
|
||||
return typeof sv === 'string' ? sv.length > 0 : sv !== null;
|
||||
};
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(
|
||||
`/api/v1/dashboards/${dashboardId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok()) {
|
||||
return false;
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
data?: { data?: { variables?: Record<string, Record<string, unknown>> } };
|
||||
};
|
||||
const vars = body?.data?.data?.variables ?? {};
|
||||
return Object.values(vars).every(isResolved);
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
message:
|
||||
'awaitVariablesResolved: dashboard.variables[*].selectedValue did not stabilise — pass `skipNames` for variables that require seeded telemetry',
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the APM Metrics dashboard by driving the real "Import JSON" UI flow:
|
||||
* opens the New-dashboard dropdown, picks Import JSON, uploads the fixture
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"description": "E2E tests for SigNoz frontend with Playwright",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"test": "playwright test",
|
||||
"test:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
@@ -42,7 +41,6 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=10.0.0 <11.0.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
84
tests/e2e/testdata/chart-data-dashboard.json
vendored
Normal file
84
tests/e2e/testdata/chart-data-dashboard.json
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"title": "detail-chart-data-suite",
|
||||
"description": "Single Time Series panel querying `signoz_calls_total` (in the bootstrap golden seed) with no variable substitution. Used by chart-data assertion tests to verify the panel renders data without inline seeding.",
|
||||
"tags": [],
|
||||
"widgets": [
|
||||
{
|
||||
"bucketCount": 30,
|
||||
"bucketWidth": 0,
|
||||
"columnUnits": {},
|
||||
"description": "",
|
||||
"fillSpans": false,
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"isStacked": false,
|
||||
"mergeAllActiveQueries": false,
|
||||
"nullZeroValues": "zero",
|
||||
"opacity": "1",
|
||||
"panelTypes": "graph",
|
||||
"query": {
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "float64",
|
||||
"id": "signoz_calls_total--float64--Sum--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "signoz_calls_total",
|
||||
"type": "Sum"
|
||||
},
|
||||
"aggregateOperator": "sum_rate",
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filters": { "items": [], "op": "AND" },
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "rps",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": 60,
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{ "disabled": false, "legend": "", "name": "A", "query": "" }
|
||||
],
|
||||
"id": "22222222-2222-4222-8222-222222222222",
|
||||
"promql": [
|
||||
{ "disabled": false, "legend": "", "name": "A", "query": "" }
|
||||
],
|
||||
"queryType": "builder"
|
||||
},
|
||||
"selectedLogFields": [
|
||||
{ "dataType": "string", "name": "body", "type": "" },
|
||||
{ "dataType": "string", "name": "timestamp", "type": "" }
|
||||
],
|
||||
"selectedTracesFields": [],
|
||||
"softMax": null,
|
||||
"softMin": null,
|
||||
"stackedBarChart": false,
|
||||
"thresholds": [],
|
||||
"timePreferance": "GLOBAL_TIME",
|
||||
"title": "E2E Metric RPS",
|
||||
"yAxisUnit": "none"
|
||||
}
|
||||
],
|
||||
"layout": [
|
||||
{
|
||||
"i": "11111111-1111-4111-8111-111111111111",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
}
|
||||
],
|
||||
"variables": {},
|
||||
"version": "v4"
|
||||
}
|
||||
136
tests/e2e/testdata/variables-dashboard.json
vendored
Normal file
136
tests/e2e/testdata/variables-dashboard.json
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"title": "detail-variables-suite",
|
||||
"description": "Seed dashboard exercising every variable type — used by detail-variables and detail-configure specs.",
|
||||
"tags": [],
|
||||
"layout": [],
|
||||
"widgets": [],
|
||||
"version": "v4",
|
||||
"variables": {
|
||||
"00000000-0000-4000-8000-000000000001": {
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"name": "tb_env",
|
||||
"order": 0,
|
||||
"type": "TEXTBOX",
|
||||
"description": "",
|
||||
"textboxValue": "otel-demo",
|
||||
"selectedValue": "otel-demo",
|
||||
"customValue": "",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000101"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000002": {
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"name": "tb_service",
|
||||
"order": 1,
|
||||
"type": "TEXTBOX",
|
||||
"description": "",
|
||||
"textboxValue": "frontend",
|
||||
"selectedValue": "frontend",
|
||||
"customValue": "",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000102"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000003": {
|
||||
"id": "00000000-0000-4000-8000-000000000003",
|
||||
"name": "cu_single",
|
||||
"order": 2,
|
||||
"type": "CUSTOM",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"selectedValue": "otel-demo",
|
||||
"customValue": "otel-demo,mq-kafka,production",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000103"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000004": {
|
||||
"id": "00000000-0000-4000-8000-000000000004",
|
||||
"name": "cu_env_all",
|
||||
"order": 3,
|
||||
"type": "CUSTOM",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "otel-demo,mq-kafka,production",
|
||||
"queryValue": "",
|
||||
"multiSelect": true,
|
||||
"showALLOption": true,
|
||||
"allSelected": true,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000104"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000005": {
|
||||
"id": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "cu_services",
|
||||
"order": 4,
|
||||
"type": "CUSTOM",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"selectedValue": ["adservice", "cartservice"],
|
||||
"customValue": "adservice,cartservice,frontend",
|
||||
"queryValue": "",
|
||||
"multiSelect": true,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000105"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000006": {
|
||||
"id": "00000000-0000-4000-8000-000000000006",
|
||||
"name": "q_env",
|
||||
"order": 5,
|
||||
"type": "QUERY",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "",
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'deployment.environment') AS `deployment.environment` FROM signoz_metrics.time_series_v4_1day GROUP BY `deployment.environment`",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000106"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000007": {
|
||||
"id": "00000000-0000-4000-8000-000000000007",
|
||||
"name": "q_service",
|
||||
"order": 6,
|
||||
"type": "QUERY",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "",
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'service.name') AS `service.name` FROM signoz_metrics.time_series_v4_1day WHERE deployment_environment = $q_env GROUP BY `service.name`",
|
||||
"multiSelect": true,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000107"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000008": {
|
||||
"id": "00000000-0000-4000-8000-000000000008",
|
||||
"name": "d_namespace",
|
||||
"order": 7,
|
||||
"type": "DYNAMIC",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"dynamicVariablesAttribute": "k8s.namespace.name",
|
||||
"dynamicVariablesSource": "metrics",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000108"
|
||||
}
|
||||
}
|
||||
}
|
||||
214
tests/e2e/tests/dashboards/details/03-viewing.spec.ts
Normal file
214
tests/e2e/tests/dashboards/details/03-viewing.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
createApmMetricsDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_TITLE = 'detail-viewing-base';
|
||||
let baseDashboardId = '';
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
baseDashboardId = await createDashboardViaApi(page, BASE_TITLE);
|
||||
seedIds.add(baseDashboardId);
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Viewing', () => {
|
||||
test('TC-01 page chrome — breadcrumb, title, toolbar buttons render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Use the APM dashboard rather than the empty base — empty dashboards
|
||||
// render an onboarding canvas with its own Configure / New Panel
|
||||
// buttons, which duplicate the toolbar testids.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(page).toHaveTitle(new RegExp(APM_METRICS_TITLE));
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /Last \d+/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'sync' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'caret-down' }),
|
||||
).toBeVisible();
|
||||
// `lock-unlock-dashboard` lives inside the Settings popover, not the
|
||||
// toolbar itself — the popover trigger is the `options` button below.
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
await expect(page.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(page.getByTestId('add-panel-header')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 breadcrumb returns to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, baseDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Dashboard /' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 tags bar renders for an imported dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// `exact: true` is load-bearing — `apm` is a substring of the
|
||||
// breadcrumb title `APM Metrics`, so a loose match would collide.
|
||||
for (const tag of ['apm', 'latency', 'error rate', 'throughput']) {
|
||||
await expect(page.getByText(tag, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 section row headers render for APM Metrics', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// known behaviour: APM Metrics fixture has two sections both named
|
||||
// "Overview" — `.first()` deliberately matches whichever renders first.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 at least one panel container renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 no JS pageerror during initial load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Cross-spec: connection with the dashboards-list page ────────────────
|
||||
|
||||
test('TC-07 navigating from the dashboards list lands on the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Mirror what the list spec validates — but as the entry path into the
|
||||
// detail page. The two suites share `apm-metrics.json` and the same
|
||||
// helpers, so a green TC here proves the seam between list and detail
|
||||
// is intact.
|
||||
await page.goto('/dashboard');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
// Filter to the seeded APM dashboard via the list search and use the
|
||||
// row action menu's View button — the documented navigation path the
|
||||
// list spec exercises.
|
||||
await page
|
||||
.getByPlaceholder('Search by name, description, or tags...')
|
||||
.fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'View' })
|
||||
.click();
|
||||
|
||||
// Land on the detail page — breadcrumb and at least one panel render.
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
441
tests/e2e/tests/dashboards/details/12-sections.spec.ts
Normal file
441
tests/e2e/tests/dashboards/details/12-sections.spec.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate section state on a single APM Metrics seed
|
||||
// (collapse / rename / add / remove). Run them serially within the worker so
|
||||
// state from one test does not leak into the next.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ─── Suite-level seed registry ───────────────────────────────────────────
|
||||
//
|
||||
// One APM Metrics dashboard powers every TC in this file (4 sections, 16
|
||||
// panels — including the duplicate-named "Overview" sections, which the
|
||||
// fixture intentionally ships). A single `afterAll` deletes every dashboard
|
||||
// the suite touched.
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Resolve the `.row-panel` container for a section by traversing up from its
|
||||
* title text. The fixture ships two sections both literally named "Overview"
|
||||
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
|
||||
* holds both the chevron and the settings-icon for that row.
|
||||
*/
|
||||
function sectionRow(
|
||||
page: Page,
|
||||
name: string | RegExp,
|
||||
index = 0,
|
||||
): ReturnType<Page['locator']> {
|
||||
return page
|
||||
.getByText(name, { exact: typeof name === 'string' })
|
||||
.nth(index)
|
||||
.locator('..')
|
||||
.locator('..');
|
||||
}
|
||||
|
||||
async function gotoApmDashboard(page: Page): Promise<void> {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await page
|
||||
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Sections', () => {
|
||||
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
|
||||
|
||||
test('TC-01 collapsing a section hides panels and shows widget count', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
|
||||
// After collapse the section title is rewritten to include the count
|
||||
// suffix; assert with a regex so the test is robust to widget-count
|
||||
// drift in the fixture.
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore: chevron-down is the row-icon variant rendered for collapsed
|
||||
// sections. Re-resolve via the new (suffixed) title.
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 widget count matches number of panels visible before collapse', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// The first Overview section in the APM fixture holds these four
|
||||
// panels — they're our ground truth for the count assertion below.
|
||||
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Request rate', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Error percentage', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Top operations', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await sectionRow(page, 'Overview', 0).locator('.lucide-chevron-up').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await sectionRow(page, 'Overview (4 widgets)')
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Collapse "DB Metrics" instead of the first Overview — its widgets
|
||||
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
|
||||
// so collapse/expand transitions can be asserted without colliding
|
||||
// with the duplicate-titled panels in the two Overview sections.
|
||||
// "DB Metrics" lives further down the canvas; scroll into view first
|
||||
// so the panels actually mount (the canvas virtualises off-screen).
|
||||
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await dbCalls.scrollIntoViewIfNeeded();
|
||||
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// While collapsed, "DB Calls RPS" should fully unmount.
|
||||
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Calls RPS', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
|
||||
|
||||
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Use DB Metrics — its settings popover is guaranteed to render all
|
||||
// three buttons when the section is expanded. WidgetRow.tsx hides
|
||||
// "Remove Section" while a section is collapsed.
|
||||
await sectionRow(page, 'DB Metrics').locator('.settings-icon').click();
|
||||
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Remove Section' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-05 rename a section, restore original name', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const renamed = `Renamed Section ${Date.now()}`;
|
||||
|
||||
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
|
||||
await sectionRow(page, 'DB Metrics').locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(renameDialog).toBeVisible();
|
||||
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
|
||||
await nameInput.click();
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(renameDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await sectionRow(page, renamed).locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
const restoreInput = restoreDialog.getByPlaceholder(
|
||||
'Enter row name here...',
|
||||
);
|
||||
await restoreInput.click();
|
||||
await restoreInput.fill('DB Metrics');
|
||||
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(restoreDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 cancel section rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await sectionRow(page, 'External calls').locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByPlaceholder('Enter row name here...');
|
||||
await input.click();
|
||||
await input.fill('Should Not Be Applied');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-07 add a new panel to a section, then delete it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const panelName = `Test Panel ${Date.now()}`;
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'New Panel', exact: true })
|
||||
.click();
|
||||
|
||||
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(panelTypeDialog).toBeVisible();
|
||||
await panelTypeDialog.getByTestId('panel-type-graph').click();
|
||||
|
||||
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
|
||||
await page.waitForURL(/\/new/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
// PUT confirms the panel persisted server-side — more reliable than
|
||||
// waiting on redux state to propagate before navigating back.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await putResponse;
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(page.getByText(panelName, { exact: true }).first()).toBeVisible();
|
||||
|
||||
|
||||
// Cleanup: open the new panel's ⋮ menu and delete via the confirm
|
||||
// dialog. The PUT-on-OK pattern again ensures the canvas has settled
|
||||
// before the test ends.
|
||||
const panelTitle = page.getByText(panelName, { exact: true }).first();
|
||||
await panelTitle.hover();
|
||||
const panelContainer = panelTitle.locator('../..');
|
||||
await panelContainer.getByTestId('widget-header-options').click();
|
||||
await page.getByRole('menuitem', { name: 'delete Delete' }).click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
|
||||
const deletePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await deletePut;
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── New section in edit mode ────────────────────────────────────────────
|
||||
|
||||
test('TC-08 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const sectionName = `Temp Section ${Date.now()}`;
|
||||
|
||||
// Enter edit mode via the toolbar options popup. The "New section"
|
||||
// button only appears once edit mode is unlocked.
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
await expect(newSectionDialog).toBeVisible();
|
||||
await newSectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await newSectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
await expect(newSectionDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(sectionName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Remove the section via its options menu. "Delete Row" is the
|
||||
// admin-only confirm dialog; verify the title before clicking OK so
|
||||
// the test fails loudly if the dialog name regresses.
|
||||
await sectionRow(page, sectionName).locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteRowDialog).toBeVisible();
|
||||
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(deleteRowDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
|
||||
|
||||
// Original sections are untouched.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await sectionRow(page, 'External calls')
|
||||
.locator('.lucide-chevron-up')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore both so the test leaves no state behind.
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await sectionRow(page, /^External calls \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
|
||||
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
|
||||
await dbPanel.first().scrollIntoViewIfNeeded();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Panels inside the collapsed section unmount, not just hidden.
|
||||
await expect(dbPanel).toHaveCount(0);
|
||||
|
||||
// Restore.
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
600
tests/e2e/tests/dashboards/details/21-panel-actions.spec.ts
Normal file
600
tests/e2e/tests/dashboards/details/21-panel-actions.spec.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createChartDataDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate the same dashboard (clone / delete panels). Run
|
||||
// them serially within the worker so state from one test does not leak into
|
||||
// another's assertions.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
const TIME_SERIES_PANEL = 'Latency';
|
||||
const TABLE_PANEL = 'Top operations';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the panel container (`.widget-graph-component-container`) for the
|
||||
* panel with the given title. The title is exposed via `data-testid={title}`
|
||||
* on the inner `Typography.Text` — traverse upward to the container so we
|
||||
* can scope the ⋮ icon, search icon, etc. to this panel only.
|
||||
*
|
||||
* Multiple panels with the same title (e.g. cloned `Latency` panels) are
|
||||
* disambiguated by `index`, defaulting to the first match in DOM order.
|
||||
*/
|
||||
function panelContainer(page: Page, title: string, index = 0): Locator {
|
||||
return page
|
||||
.getByTestId(title)
|
||||
.nth(index)
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class, "widget-graph-component-container")][1]',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the panel header (the ⋮ icon is CSS-hidden until the row is hovered)
|
||||
* and open the action dropdown. Returns the opened menu locator.
|
||||
*
|
||||
* The antd `<Dropdown>` wrapping the ⋮ icon uses `trigger={['hover']}` (see
|
||||
* `WidgetHeader/index.tsx`), so the menu opens on hover, not click —
|
||||
* dispatching a click is a no-op. We hover the container first to reveal the
|
||||
* icon (it's CSS-hidden until then) and then hover the icon itself to fire
|
||||
* the antd Dropdown's mouseenter handler.
|
||||
*/
|
||||
async function openPanelMoreMenu(
|
||||
page: Page,
|
||||
title: string,
|
||||
index = 0,
|
||||
): Promise<Locator> {
|
||||
const container = panelContainer(page, title, index);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
const moreOptions = container.getByTestId('widget-header-options');
|
||||
await moreOptions.hover();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
return menu;
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Panel Actions', () => {
|
||||
// ─── ⋮ menu contents ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-01 panel ⋮ menu shows the 5 actions for a Time Series panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
|
||||
// Time Series headerMenuList = ViewMenuAction + EditMenuAction
|
||||
// = [View, Clone, Delete, Edit, CreateAlerts]. Download is hidden
|
||||
// because panelTypes !== TABLE.
|
||||
await expect(menu.getByRole('menuitem')).toHaveCount(5);
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'fullscreen View' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'edit Edit' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'copy Clone' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'delete Delete' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-02 Table panel ⋮ menu replaces Create Alerts with Download as CSV', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// Table panels filter CreateAlerts out of the menu (see GridCard
|
||||
// `menuList`) and the Download item turns visible because
|
||||
// panelTypes === TABLE.
|
||||
await expect(
|
||||
menu.getByRole('menuitem', {
|
||||
name: 'cloud-download Download as CSV',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─── View / Fullscreen ───────────────────────────────────────────────────
|
||||
|
||||
test('TC-03 View action opens fullscreen with `expandedWidgetId` URL param', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'fullscreen View' });
|
||||
// The View menuitem is `disabled: queryResponse.isFetching` — wait
|
||||
// for it to become enabled before clicking, otherwise the click is a
|
||||
// no-op and the dialog never opens.
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-04 fullscreen panel renders chart canvas or "No Data"', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'fullscreen View' });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so a
|
||||
// fully-rendered chart and a "No Data" empty state are both valid
|
||||
// terminal states. Both can also coexist (the chart canvas mounts
|
||||
// before the empty-state overlay paints), so assert that at least
|
||||
// one of the two is reachable rather than using `.or().toBeVisible()`
|
||||
// — that combination triggers strict-mode violations when both
|
||||
// matches resolve.
|
||||
const canvas = dialog.locator('canvas');
|
||||
const noData = dialog.getByText(/no data/i);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await canvas.count()) + (await noData.count()),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Table search ────────────────────────────────────────────────────────
|
||||
|
||||
test('TC-05 Table panel search icon reveals search input', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
|
||||
// The search icon is hover-revealed; click it to swap the title row
|
||||
// out for the search input.
|
||||
const searchIcon = container.getByTestId('widget-header-search');
|
||||
await searchIcon.click();
|
||||
|
||||
// When `showGlobalSearch` is true, the WidgetHeader unmounts the
|
||||
// Typography.Text that carries the title's `data-testid`, so the
|
||||
// `panelContainer` ancestor chain no longer resolves. Look up the
|
||||
// search input by its testid directly — only one search input is
|
||||
// ever open at a time on a dashboard.
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('test');
|
||||
await expect(searchInput).toHaveValue('test');
|
||||
});
|
||||
|
||||
// ─── Download as CSV ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 Download as CSV triggers a file download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// known behaviour: with no telemetry, the CSV may contain only the
|
||||
// header row — asserting on `suggestedFilename()` is the resilient
|
||||
// cross-environment signal that the download actually fired.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'cloud-download Download as CSV',
|
||||
})
|
||||
.click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ─── Clone / Delete ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Clone unconditionally navigates to the panel editor (`/new`) — see
|
||||
// `onCloneHandler` in WidgetGraphComponent. Saving from the editor
|
||||
// returns to the dashboard with the duplicated panel persisted.
|
||||
|
||||
test('TC-07 Clone a panel creates a duplicate', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const cloneItem = menu.getByRole('menuitem', { name: 'copy Clone' });
|
||||
await expect(cloneItem).toBeEnabled();
|
||||
await cloneItem.click();
|
||||
|
||||
// The clone handler PUTs the new layout, then redirects to /new.
|
||||
await page.waitForURL(/\/new/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
|
||||
// The Save dialog title varies — "Save Widget" if the query is
|
||||
// untouched (the case here, since clone preserves the original
|
||||
// query) or "Unsaved Changes" otherwise. Match either by clicking
|
||||
// OK in whichever dialog appears.
|
||||
const saveDialog = page.getByRole('dialog');
|
||||
await expect(saveDialog).toBeVisible();
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Per-test cleanup: delete the newly cloned panel so it does not
|
||||
// leak into subsequent tests. The clone is the last panel with this
|
||||
// title in DOM order — index `beforeCount`.
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
test('TC-08 Delete confirm dialog removes a cloned panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel — never mutate the seed's original
|
||||
// `Latency` panel because sibling specs depend on it.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'copy Clone' }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone — last `Latency` in DOM order.
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(/are you sure/i);
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
test('TC-09 Cancel delete keeps the panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel to operate on.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'copy Clone' }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
// Cancel keeps the clone in place — count unchanged from the
|
||||
// post-clone state.
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Per-test cleanup: actually delete the clone we just kept so
|
||||
// subsequent tests start from the seeded count.
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// ─── Create Alerts ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-10 Create Alerts menuitem on a Time Series panel navigates to the alerts editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const createAlerts = menu.getByRole('menuitem', {
|
||||
name: /Create Alerts/,
|
||||
});
|
||||
await expect(createAlerts).toBeEnabled();
|
||||
|
||||
// known behaviour: `useCreateAlerts` opens the alerts editor in a
|
||||
// new tab via `window.open(...)` — the current page's URL does not
|
||||
// change. Wait for the new browser tab on the context, not the
|
||||
// existing page.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
createAlerts.click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
await expect(newPage).toHaveURL(/\/alerts\/new/);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 fullscreen URL deep-link opens the panel modal directly', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// First navigate normally and capture the panel's widgetId from the
|
||||
// View action's URL transition — we cannot hard-code a uuid.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'fullscreen View' });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
const expandedUrl = page.url();
|
||||
await page.getByRole('dialog', { name: TIME_SERIES_PANEL }).getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
// Now hard-navigate to the captured deep-link in a fresh page state.
|
||||
await page.goto(expandedUrl);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: TIME_SERIES_PANEL }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-12 Table panel search filters rows in real time', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const tableTitle = page.getByText(TABLE_PANEL, { exact: true }).first();
|
||||
await expect(tableTitle).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
await container.getByTestId('widget-header-search').click();
|
||||
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so the
|
||||
// table body may be empty. The contract this TC guards is "typing in
|
||||
// the search updates the input value live and does not throw" — a
|
||||
// rendered row count check only fires when telemetry happens to seed
|
||||
// rows. We log no console errors during the search keystrokes either.
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await searchInput.fill('foo');
|
||||
await expect(searchInput).toHaveValue('foo');
|
||||
await searchInput.fill('');
|
||||
await expect(searchInput).toHaveValue('');
|
||||
await searchInput.fill('bar-baz');
|
||||
await expect(searchInput).toHaveValue('bar-baz');
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// TC-13 asserts the panel renders chart data from the bootstrap golden
|
||||
// seed (Playwright globalSetup refreshes timestamps before every test
|
||||
// session, so the data is always within default panel windows).
|
||||
|
||||
test('TC-13 panel renders chart data from the bootstrap golden seed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const chartId = await createChartDataDashboardViaApi(page);
|
||||
seedIds.add(chartId);
|
||||
|
||||
await page.goto(`/dashboard/${chartId}`);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-chart-data-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const panel = page
|
||||
.getByText('E2E Metric RPS', { exact: true })
|
||||
.first()
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.locator('canvas').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const dimensions = await panel
|
||||
.locator('canvas')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
const c = el as HTMLCanvasElement;
|
||||
return { w: c.width, h: c.height };
|
||||
});
|
||||
expect(dimensions.w).toBeGreaterThan(0);
|
||||
expect(dimensions.h).toBeGreaterThan(0);
|
||||
|
||||
// Empty-state must NOT render — proves the golden seed landed and
|
||||
// the panel query found rows.
|
||||
await expect(panel.getByText(/no data/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-14 Delete only removes the targeted panel — siblings remain', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// "DB Calls RPS" is a single-instance Time Series panel in APM Metrics
|
||||
// — a stable sibling we can assert is still on the canvas after a
|
||||
// clone+delete round-trip on the Latency panel. Scroll into view since
|
||||
// it lives in a later section.
|
||||
const sibling = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await sibling.scrollIntoViewIfNeeded();
|
||||
await expect(sibling).toBeVisible();
|
||||
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone first so the test is read-only at the seed level.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'copy Clone' }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone (last in DOM order).
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Delete' })
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
|
||||
// Originals + siblings still present.
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
await expect(sibling).toBeVisible();
|
||||
});
|
||||
});
|
||||
124
tests/e2e/tests/dashboards/details/35-add-panel.spec.ts
Normal file
124
tests/e2e/tests/dashboards/details/35-add-panel.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Scope: dashboard-side seams only —
|
||||
// 1. The toolbar "New Panel" button opens a dialog listing every panel type
|
||||
// the app supports (the dashboard's responsibility).
|
||||
// 2. A panel created from the dialog actually lands on the canvas and
|
||||
// survives a hard reload (the dashboard's persistence contract).
|
||||
//
|
||||
// Editor-internal behaviour (Query Builder vs ClickHouse tab, Panel Settings,
|
||||
// y-axis units, panel-type changes, etc.) belongs in a separate panel-editor
|
||||
// spec — do NOT add those here.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () => {
|
||||
test('TC-01 New Panel toolbar button opens a dialog listing all 7 panel types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-dialog-${ts}`);
|
||||
seedIds.add(id);
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
// Empty dashboards render an onboarding canvas with a duplicate
|
||||
// `add-panel-header` CTA. Scope to the toolbar (`.right-section`).
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const tile of [
|
||||
'panel-type-graph',
|
||||
'panel-type-value',
|
||||
'panel-type-table',
|
||||
'panel-type-list',
|
||||
'panel-type-bar',
|
||||
'panel-type-pie',
|
||||
'panel-type-histogram',
|
||||
]) {
|
||||
await expect(dialog.getByTestId(tile)).toBeVisible();
|
||||
}
|
||||
|
||||
// Dialog dismisses via the Close (×) button — confirms the user can
|
||||
// back out without entering the editor (no /new navigation happens
|
||||
// until a tile is picked).
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
});
|
||||
|
||||
test('TC-02 saving a new panel persists it on the canvas across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-persist-${ts}`);
|
||||
seedIds.add(id);
|
||||
const panelName = `e2e-panel-${ts}`;
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'New Panel' })
|
||||
.getByTestId('panel-type-graph')
|
||||
.click();
|
||||
|
||||
// We're now on the editor; minimal interaction — set the name and save.
|
||||
// Anything else (queries, panel-type changes, units) is editor-internal
|
||||
// and belongs in a panel-editor spec.
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
const savePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Save Widget' })
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
const putResp = await savePut;
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
// Back on the dashboard — the new panel must render with the typed name.
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Persistence — hard reload, panel still there.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
78
tests/e2e/tests/dashboards/details/44-edit-panel.spec.ts
Normal file
78
tests/e2e/tests/dashboards/details/44-edit-panel.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// This file's scope is intentionally narrow: prove that the detail page's
|
||||
// "Edit panel" entry-point lands the user in the panel editor at
|
||||
// `/dashboard/:id/new?widgetId=…`. Editor-internal behaviour (Query Builder
|
||||
// pre-population, ClickHouse tab, Panel Settings rename, query-edit + revert,
|
||||
// y-axis units, panel-type changes, etc.) is the responsibility of a separate
|
||||
// panel-editor spec — keep this file as the dashboard-side seam only.
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Edit Panel (entry-point only)', () => {
|
||||
test('TC-01 Edit menu item on a panel navigates to the panel editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// "DB Calls RPS" is the only single-instance panel name in the APM
|
||||
// Metrics fixture (other titles like "Latency" repeat across sections),
|
||||
// so it round-trips uniquely without `.first()` gymnastics.
|
||||
const panelTitle = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await panelTitle.scrollIntoViewIfNeeded();
|
||||
|
||||
// Walk up to the widget-graph container. Its `:hover` flips the ⋮ icon
|
||||
// from `visibility: hidden` to visible (see GridCardLayout.styles.scss
|
||||
// rule on `.widget-graph-component-container:hover .options-action`).
|
||||
const container = panelTitle.locator(
|
||||
'xpath=ancestor::*[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await container.hover();
|
||||
|
||||
const options = container.getByTestId('widget-header-options');
|
||||
// The ⋮ uses an antd `Dropdown` with `trigger=['hover']`; firing a real
|
||||
// hover (not `dispatchEvent('click')`) is what opens the menu.
|
||||
await options.hover({ force: true });
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+\/new\?.*widgetId=/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
});
|
||||
});
|
||||
245
tests/e2e/tests/dashboards/details/56-time-range.spec.ts
Normal file
245
tests/e2e/tests/dashboards/details/56-time-range.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openTimePicker(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('textbox', { name: /Last \d+/ })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Time Range', () => {
|
||||
test('TC-01 selecting a preset updates the textbox label and URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
|
||||
const refetch = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Last 1 hour 1h' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 1 hour' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1h/);
|
||||
// Without seeded telemetry the backend may return 4xx for query_range
|
||||
// (panels render "No Data" — a known harness limitation, not a test
|
||||
// bug). Cancelled in-flight responses also surface here as non-ok.
|
||||
// Only 5xx is a real failure; the URL + textbox label assertions
|
||||
// above already prove the preset click took effect.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-02 switching presets twice updates the label both times', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 6 hours 6h' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 6 hours' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=6h/);
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 1 day 1d' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 1 day' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1d/);
|
||||
await expect(page).not.toHaveURL(/relativeTime=6h/);
|
||||
});
|
||||
|
||||
test('TC-03 custom date range picker reflects selected dates and switches URL to absolute timestamps', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Custom Date Range' }).click();
|
||||
|
||||
const prevMonth = page.getByRole('button', {
|
||||
name: 'Go to the Previous Month',
|
||||
});
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await prevMonth.click();
|
||||
}
|
||||
|
||||
// Calendar day buttons have accessible names like "Saturday, March
|
||||
// 14th, 2026" (the rendered label is "14" but a11y appends the suffix
|
||||
// + month + year). Pick a known day by its long-form name regex
|
||||
// against the gridcell — `\b14th\b` is unambiguous and avoids
|
||||
// matching siblings like "14" inside "2014".
|
||||
await page
|
||||
.getByRole('gridcell', { name: /\b14th\b/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const refetch = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /\d{2}\/\d{2}\/\d{4}/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/startTime=\d+/);
|
||||
await expect(page).toHaveURL(/endTime=\d+/);
|
||||
// As TC-01: backend 4xx (no telemetry) is acceptable; only 5xx is
|
||||
// failure. Apply triggered the refetch, which is what we verify.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-04 timezone change updates the toolbar timezone label', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Change Timezone' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Search timezones...' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Coordinated Universal Time —/ })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText('UTC', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 refresh-interval popup contents', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'caret-down' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await expect(autoRefresh).toBeVisible();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
|
||||
// Labels match the live build (no `15 minutes` / `12 hours` — the
|
||||
// test plan's enumeration was approximate).
|
||||
for (const label of [
|
||||
'5 seconds',
|
||||
'10 seconds',
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
'30 minutes',
|
||||
'1 hour',
|
||||
'2 hours',
|
||||
'1 day',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: label, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-06 toggling auto-refresh on then changing the interval', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'caret-down' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: '1 minute', exact: true }).click();
|
||||
await page.getByRole('button', { name: '5 minutes', exact: true }).click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-07 manual sync triggers a query_range refetch', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const refetch = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await page.getByRole('button', { name: 'sync' }).click();
|
||||
const response = await refetch;
|
||||
// 4xx is expected without seeded telemetry; only 5xx is a failure.
|
||||
// The sync click successfully triggering a query_range fetch is the
|
||||
// behaviour under test.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
497
tests/e2e/tests/dashboards/details/67-variables.spec.ts
Normal file
497
tests/e2e/tests/dashboards/details/67-variables.spec.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
import variablesTemplate from '../../../testdata/variables-dashboard.json';
|
||||
|
||||
// Variables that depend on backend resolution against seeded telemetry the
|
||||
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
|
||||
// does not block on values that can never appear.
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let varDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
varDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
'detail-variables-suite',
|
||||
);
|
||||
seedIds.add(varDashboardId);
|
||||
// Per the framework contract: every variable with a default has its
|
||||
// `selectedValue` set in the seed JSON; backend-resolved variables
|
||||
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
|
||||
// list them in `skipNames`. Tests must not race ahead of seed
|
||||
// materialisation — this gate ensures the persisted dashboard is in
|
||||
// a known state before any test runs.
|
||||
await awaitVariablesResolved(page, varDashboardId, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function variablesQueryParam(state: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
async function gotoVariablesDashboard(
|
||||
page: Page,
|
||||
urlState?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const url = urlState
|
||||
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
|
||||
: `/dashboard/${varDashboardId}`;
|
||||
await page.goto(url);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Variables', () => {
|
||||
test('TC-01 variables bar renders all four types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
for (const name of [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
]) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Textbox variables expose their current value via `value` and `title`
|
||||
// attributes (the antd Input has no accessible name matching the value),
|
||||
// so we match on input[value="..."] rather than getByRole+name.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
await expect(page.locator('input[value="frontend"]')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('variable-select')).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// $cu_single (nth(0)) — single-select Custom with three static
|
||||
// options. Driving Custom rather than Query keeps the test
|
||||
// deterministic regardless of seeded telemetry.
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*mq-kafka/);
|
||||
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-03 multi-select renders chips and URL encodes array', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// URL state seeds adservice + cartservice as initial selection; this also
|
||||
// guarantees the URL contains the encoded array so we can assert on it
|
||||
// without relying on the seeded server-side selection rendering identically
|
||||
// across reloads.
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/adservice/);
|
||||
await expect(page).toHaveURL(/cartservice/);
|
||||
});
|
||||
|
||||
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
|
||||
|
||||
// Removing a chip on a multi-select expands the dropdown; URL state
|
||||
// only commits when the dropdown closes (onDropdownVisibleChange =>
|
||||
// false). The CustomMultiSelect swallows Escape, so click outside the
|
||||
// dropdown to dismiss it.
|
||||
await page.locator('img[alt="dashboard-img"]').click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
await expect(page).not.toHaveURL(/adservice/);
|
||||
});
|
||||
|
||||
test('TC-05 ALL option on a Custom variable', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
|
||||
|
||||
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
|
||||
// so the dropdown exposes an "ALL" toggle alongside the static options.
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', {
|
||||
hasText: 'otel-demo',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'ALL' }).click();
|
||||
|
||||
// When ALL is selected, the multi-select renders an "ALL" badge in a
|
||||
// custom container (not the standard .ant-select-selection-item), so
|
||||
// match on the option's checked state inside the dropdown listbox
|
||||
// rather than on the closed-state chip.
|
||||
await expect(
|
||||
page.getByRole('option', { name: 'ALL' }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
});
|
||||
|
||||
test('TC-06 textbox variable update propagates to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Locate by the testid wrapping a stable id, since `input[value="..."]`
|
||||
// becomes stale the moment we fill('') the field.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
const tb = page.getByPlaceholder('Enter value').first();
|
||||
await tb.click();
|
||||
await tb.fill('');
|
||||
await tb.fill('production');
|
||||
await tb.press('Enter');
|
||||
|
||||
await expect(page.locator('input[value="production"]')).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*production/);
|
||||
});
|
||||
|
||||
test('TC-07 cascading: child variable listbox opens after parent change', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
|
||||
|
||||
// q_service (nth(4)) is cascaded from q_env (nth(3)).
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
|
||||
// known behaviour: the child's option list requires seeded telemetry —
|
||||
// the bootstrap stack has none, so we only assert that the listbox
|
||||
// renders without crashing rather than checking specific options.
|
||||
await expect(page.getByRole('listbox').first()).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
});
|
||||
|
||||
test('TC-08 URL deep-link restores variable state on hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
|
||||
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
|
||||
|
||||
// Seed defaults to ALL — open, pick a specific value, assert URL.
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/mq-kafka/);
|
||||
|
||||
// Re-open, switch back to ALL — URL must update again.
|
||||
await dropdown.click();
|
||||
const allOption = page.getByRole('option', { name: 'ALL' });
|
||||
await allOption.click();
|
||||
await expect(allOption).toHaveAttribute('aria-selected', 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
|
||||
await expect(page).not.toHaveURL(/mq-kafka/);
|
||||
});
|
||||
|
||||
test('TC-10 two variables changed in sequence both encode in URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// cu_single — pick `production`.
|
||||
const single = page.getByTestId('variable-select').nth(0);
|
||||
await single.click();
|
||||
await page.getByRole('option', { name: 'production' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
|
||||
// q_service — open the multi-select, dismiss without picking. The URL
|
||||
// should still contain the previous selection.
|
||||
const cuServices = page.getByTestId('variable-select').nth(2);
|
||||
await cuServices.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
await expect(page).toHaveURL(/cu_single/);
|
||||
});
|
||||
|
||||
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
const stateUrl = page.url();
|
||||
|
||||
// Leave to the list, come back via browser back — URL is restored.
|
||||
await page.getByRole('button', { name: 'Dashboard /' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(stateUrl);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
|
||||
// They are intentional gaps, not bugs — when the feature ships or the seed
|
||||
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
|
||||
// implement.
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
|
||||
// Requires extending variables-dashboard.json with a Custom variable
|
||||
// that has no `selectedValue` and no `allSelected`. The UI should
|
||||
// render the dropdown empty/"Select value" until a user picks.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
|
||||
// Requires extending variables-dashboard.json with a Query variable
|
||||
// that ships with `selectedValue` already populated — the UI should
|
||||
// trust the seed and not block on a query.
|
||||
});
|
||||
|
||||
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// q_service is the only multi-select Query in the seed (nth(4) in
|
||||
// the dropdown order). Without telemetry the option list is empty —
|
||||
// assert the empty-state explicitly.
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
const listbox = page.getByRole('listbox').first();
|
||||
await expect(listbox).toBeVisible();
|
||||
await expect(listbox.getByRole('option')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
|
||||
// over the `metrics` source. The bootstrap OTel collector ingests
|
||||
// the golden dataset which tags every resource with
|
||||
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
|
||||
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
|
||||
// naturally by the collector's signozclickhousemetrics exporter, and
|
||||
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
|
||||
// surfaces the values so the Dynamic variable auto-resolves.
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// d_namespace is the 6th dropdown variable in DOM order. The
|
||||
// closed-state of the combobox renders the auto-resolved value
|
||||
// inline next to the variable name. Match any of the 8 seeded
|
||||
// namespaces — ordering depends on the backend sort, so we accept
|
||||
// whichever it returns first.
|
||||
const dynamic = page.getByTestId('variable-select').nth(5);
|
||||
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
|
||||
// $service.name and $deployment.environment are referenced by APM
|
||||
// panel queries. Asserting that a variable change triggers a
|
||||
// query_range refetch with the new substitution requires either
|
||||
// seeded telemetry or a network-request listener that confirms the
|
||||
// outbound query body contains the new value. Defer until the
|
||||
// chart-data assertion path is in place.
|
||||
});
|
||||
|
||||
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Extract DOM order of `$<name>` labels in the variables bar and
|
||||
// compare against the `order` sequence the seed JSON declares
|
||||
// (tb_env=0, tb_service=1, cu_single=2, cu_env_all=3, cu_services=4,
|
||||
// q_env=5, q_service=6, d_namespace=7).
|
||||
const expected = [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
];
|
||||
// Read the on-screen label text in DOM order. Each `$<name>` label is
|
||||
// emitted as plain text inside the variables bar — `allInnerTexts()`
|
||||
// preserves their order. Filter to `$<word>` to exclude any other
|
||||
// transient text inside the bar.
|
||||
const allText = await page
|
||||
.locator('text=/^\\$\\w+$/')
|
||||
.allInnerTexts();
|
||||
const actual = allText.filter((t) => /^\$\w+$/.test(t));
|
||||
// Sort comparison is intentionally strict: an order regression would
|
||||
// silently swap pairs without a deep equal check.
|
||||
expect(actual.slice(0, expected.length)).toEqual(expected);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
|
||||
// The Configure → Variables tab supports drag handles. After a
|
||||
// reorder, the persisted `order` fields should update and the
|
||||
// variables bar should re-render in the new order.
|
||||
});
|
||||
|
||||
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// `tb_service` is the easiest variable to remove cleanly — it's a
|
||||
// textbox, no dependents. Delete it via Configure → Variables tab.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// Configure list no longer shows it.
|
||||
await expect(tabpanel.getByText('tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
// Variables bar no longer shows `$tb_service`.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Restore — re-PUT the seed so subsequent serial-mode tests are
|
||||
// not affected. Only this test mutates the persisted variable map;
|
||||
// the rest only mutate URL state.
|
||||
const token = await authToken(page);
|
||||
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
|
||||
data: { ...variablesTemplate, title: 'detail-variables-suite' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
354
tests/e2e/tests/dashboards/details/78-edit-mode.spec.ts
Normal file
354
tests/e2e/tests/dashboards/details/78-edit-mode.spec.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openEditMode(page: Page): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
}
|
||||
|
||||
async function closeEditModeIfOpen(page: Page): Promise<void> {
|
||||
const lockBtn = page.getByRole('button', { name: 'Lock Dashboard' });
|
||||
if (await lockBtn.isVisible().catch(() => false)) {
|
||||
await lockBtn.click({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Edit Mode', () => {
|
||||
test('TC-01 edit-mode popup contains all six action buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-popup');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Full screen' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'New section' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export JSON' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copy as JSON' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-02 Lock Dashboard exits edit mode', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-lock');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'New section' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export JSON' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 rename dashboard — breadcrumb updates, then restore', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `original-${ts}`;
|
||||
const renamed = `Renamed-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
await expect(renameDialog).toBeVisible();
|
||||
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog
|
||||
.getByRole('button', { name: 'Rename Dashboard' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const restoreDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const restoreInput = restoreDialog.getByTestId('dashboard-name');
|
||||
await restoreInput.fill('');
|
||||
await restoreInput.fill(original);
|
||||
await restoreDialog
|
||||
.getByRole('button', { name: 'Rename Dashboard' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 cancel rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cancel-rename-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill('Should Not Be Saved');
|
||||
|
||||
await renameDialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Saved')).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-05 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await seed(page, `edit-mode-section-${ts}`);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const sectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
const sectionName = `e2e-section-${ts}`;
|
||||
await sectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await sectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
|
||||
const sectionTitle = page
|
||||
.locator('.section-title')
|
||||
.filter({ hasText: sectionName });
|
||||
await expect(sectionTitle).toBeVisible();
|
||||
|
||||
// Cleanup — remove the section. The ellipsis trigger sits on the
|
||||
// `.row-panel` container alongside the section title; the popover it
|
||||
// opens has rootClassName="row-settings" and renders at body level.
|
||||
const sectionRow = sectionTitle.locator('xpath=ancestor::*[contains(@class, "row-panel")]');
|
||||
await sectionRow.hover();
|
||||
await sectionRow.locator('.settings-icon').click();
|
||||
const rowSettingsPopover = page.locator('.row-settings');
|
||||
await expect(rowSettingsPopover).toBeVisible();
|
||||
await rowSettingsPopover
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await expect(sectionTitle).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-06 Export JSON triggers a .json download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-export');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export JSON' }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-07 Copy as JSON puts dashboard JSON on the clipboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const ts = Date.now();
|
||||
const title = `edit-mode-copy-${ts}`;
|
||||
const id = await seed(page, title);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Copy as JSON' }).click();
|
||||
|
||||
const clipboardText = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const parsed = JSON.parse(clipboardText) as { title?: string };
|
||||
expect(parsed.title ?? '').toContain(title);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// known behaviour: headless Chromium does not honour the Fullscreen API,
|
||||
// so we cannot assert `document.fullscreenElement`. Verifying that the
|
||||
// click is benign (breadcrumb still rendered) is the strongest cross-env
|
||||
// check available.
|
||||
test('TC-08 Full screen — clicking does not crash the dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-fullscreen');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Full screen' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 lock → unlock round-trip restores edit-mode controls', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-lock-roundtrip');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// Lock the dashboard.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Re-opening the popup after a lock shows the Unlock label instead of
|
||||
// Lock. The button label flips based on `isDashboardLocked`.
|
||||
await openEditMode(page);
|
||||
const unlockBtn = page.getByRole('button', { name: 'Unlock Dashboard' });
|
||||
await expect(unlockBtn).toBeVisible();
|
||||
await unlockBtn.click();
|
||||
|
||||
// After unlock, the popup should re-expose the original action buttons.
|
||||
await openEditMode(page);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'New section' }),
|
||||
).toBeVisible();
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-10 rename persists across hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `rename-persist-${ts}`;
|
||||
const renamed = `Renamed-Persist-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog
|
||||
.getByRole('button', { name: 'Rename Dashboard' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Hard reload — name must still be the renamed one.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveTitle(new RegExp(renamed));
|
||||
});
|
||||
});
|
||||
686
tests/e2e/tests/dashboards/details/87-configure.spec.ts
Normal file
686
tests/e2e/tests/dashboards/details/87-configure.spec.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
|
||||
// so this file still compiles while it is missing — tests that need it skip
|
||||
// at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dashboardsHelpers = require('../../../helpers/dashboards') as {
|
||||
createVariablesDashboardViaApi?: (
|
||||
page: Page,
|
||||
title: string,
|
||||
) => Promise<string>;
|
||||
};
|
||||
const hasVariablesHelper =
|
||||
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedVariablesDashboard(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
|
||||
throw new Error('createVariablesDashboardViaApi helper is not available');
|
||||
}
|
||||
const id = await dashboardsHelpers.createVariablesDashboardViaApi(
|
||||
page,
|
||||
title,
|
||||
);
|
||||
seedIds.add(id);
|
||||
// Wait for the seeded dashboard's variables to fully resolve before any
|
||||
// caller test acts on them. Variables with defaults already have
|
||||
// `selectedValue` set; Query/Dynamic variables can't resolve without
|
||||
// telemetry and are skipped.
|
||||
await awaitVariablesResolved(page, id, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openConfigureDrawer(page: Page) {
|
||||
// An empty dashboard renders an onboarding canvas with a duplicate
|
||||
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
|
||||
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
|
||||
// strict-mode collision.
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function deleteVariableByName(page: Page, varName: string) {
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
// Walk up to the surrounding row container to scope the delete-button
|
||||
// search; `.variable-item` (or the variable row container) wraps the
|
||||
// hover-revealed delete button.
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Configure drawer', () => {
|
||||
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-drawer-chrome');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('tab', { name: 'Overview' }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Overview' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 update name, description, and tag — persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cfg-overview-save-${ts}`;
|
||||
const updated = `Configured-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await nameInput.click();
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(updated);
|
||||
|
||||
await dialog
|
||||
.getByTestId('dashboard-desc')
|
||||
.fill('Automated test description');
|
||||
|
||||
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.fill(`e2e-tag-${ts}`);
|
||||
await tagInput.press('Enter');
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save' });
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
const [putResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
),
|
||||
saveBtn.click({ force: true }),
|
||||
]);
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${updated}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Discard reverts unsaved Overview changes', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const original = 'cfg-overview-discard';
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
|
||||
await nameInput.fill('Temp Modified Name');
|
||||
const discard = dialog.getByRole('button', { name: 'Discard' });
|
||||
await expect(discard).toBeVisible();
|
||||
await discard.click();
|
||||
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save' }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-04 Variables tab lists existing variables', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(tabpanel).toBeVisible();
|
||||
|
||||
for (const varName of [
|
||||
'tb_env',
|
||||
'tb_service',
|
||||
'cu_env_all',
|
||||
'cu_services',
|
||||
'q_env',
|
||||
'q_service',
|
||||
'd_namespace',
|
||||
]) {
|
||||
// Variable rows render as plain text inside the Variables tab
|
||||
// (not a true Antd `Table` with role="row"). Locate via text.
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `tb_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await saveBtn.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(`$${varName}`)).toBeVisible();
|
||||
|
||||
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
|
||||
await newTextbox.fill('test-value');
|
||||
await newTextbox.press('Enter');
|
||||
await expect(page).toHaveURL(/test-value/);
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
test('TC-06 add a Custom variable — appears in the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `custom_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
|
||||
// not implemented. Dynamic variables source from the SigNoz attribute
|
||||
// index — the bootstrap stack ingests no telemetry, so the field selector
|
||||
// renders an empty option list and Save Variable can never be enabled.
|
||||
// Re-add once the bootstrap seeds telemetry attributes.
|
||||
|
||||
test('TC-08 selecting Query type renders the query editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `query_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill(varName);
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-09 Save Variable disabled when name is empty', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-variables-empty-name');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
const nameField = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameField).toHaveValue('');
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-10 Publish tab shows private message and Publish button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-publish');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Publish' }).click();
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Publish' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('checkbox', { name: 'Enable time range' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Dashboard variables won't work in public dashboards"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Publish dashboard' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// `test.skip` placeholders for behaviours not yet covered. Replace with
|
||||
// `test` and implement when the corresponding feature ships or the seed
|
||||
// gains the necessary state.
|
||||
|
||||
test('TC-11 edit existing variable — rename', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
// Hover the row to reveal the edit button (Pylon overlay can intercept,
|
||||
// so dispatchEvent fires the click directly on the React onClick).
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Editor form mounts; rename and save.
|
||||
const renamed = `tb_env_renamed_${Date.now()}`;
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill(renamed);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Variables bar reflects the rename; the original label is gone.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-type');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Change type from Custom to Query and verify the form swaps to the
|
||||
// Query editor (Monaco SQL editor mounts where the comma-separated
|
||||
// values input used to live).
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible();
|
||||
|
||||
// The previous Custom-specific fields must no longer be visible.
|
||||
await expect(
|
||||
dialog.getByPlaceholder(/Comma separated values/i),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Discard rather than save — saving without filling the new query
|
||||
// would leave a half-configured Query variable. The contract this TC
|
||||
// guards is "type switching swaps the form correctly", which the
|
||||
// assertions above already prove.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-default');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Update the default textbox value. The Default Value input is the
|
||||
// second/third field (Name first); locate it via its placeholder.
|
||||
const defaultInput = dialog
|
||||
.getByPlaceholder(/Enter default value|Default value/i)
|
||||
.first();
|
||||
await defaultInput.fill('new-default');
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Reload — the new default renders without URL state because it's
|
||||
// now the persisted seed value.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(page.locator('input[value="new-default"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-14 delete variable — removed from variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Reuse the existing helper and assert the variables bar reflects
|
||||
// the deletion — `deleteVariableByName` covers the Configure-side
|
||||
// removal; the bar update is the new contract this TC adds.
|
||||
await deleteVariableByName(page, 'tb_env');
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
// Sibling textbox is unaffected.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill('tb_env');
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
// Save Variable should refuse to enable while the name collides with
|
||||
// an existing variable. Assert the button stays disabled, OR a
|
||||
// validation message surfaces — UI may pick either signal.
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
|
||||
|
||||
// Either Save is disabled, or an explicit error is shown — both are
|
||||
// valid contracts. `Promise.race` between the two assertions tolerates
|
||||
// whichever the UI provides.
|
||||
await expect.poll(async () => {
|
||||
const disabled = await saveBtn.isDisabled().catch(() => false);
|
||||
const err = await errorMsg.isVisible().catch(() => false);
|
||||
return disabled || err;
|
||||
}).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
|
||||
// Names containing spaces, $-prefix, dots, etc. should be rejected
|
||||
// by the validator. Confirm Save Variable stays disabled with an
|
||||
// inline error message.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
|
||||
// The Variables tab supports drag handles. After a reorder, the
|
||||
// persisted `data.variables[*].order` reflects the new sequence and
|
||||
// the variables bar re-renders accordingly.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
|
||||
// Dynamic-variable resolution itself is covered by
|
||||
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
|
||||
// namespace → URL state updates). What this TC adds is the Configure
|
||||
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
|
||||
// uses a combobox whose stable locator hasn't been pinned in this
|
||||
// suite yet — leave skipped pending a snapshot pass.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
|
||||
// `description` field on each variable should be surfaced in the
|
||||
// variables bar tooltip and in the Variables tab's row.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
|
||||
// For a Query variable mid-resolution, Save Variable should be
|
||||
// disabled until the query returns options. Otherwise we'd save
|
||||
// a variable with an empty option list.
|
||||
});
|
||||
|
||||
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Open the editor for tb_env and dirty the Name field.
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill('SHOULD_NOT_PERSIST');
|
||||
|
||||
// Discard, then re-open the same row. The Name must still be the
|
||||
// original — abandoned edits never reach the persisted JSON.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Reopen Configure → tb_env still has the original name.
|
||||
const dialog2 = await openConfigureDrawer(page);
|
||||
await dialog2.getByRole('tab', { name: 'Variables' }).click();
|
||||
await expect(
|
||||
dialog2
|
||||
.getByRole('tabpanel', { name: 'Variables' })
|
||||
.getByText('tb_env', { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
247
tests/e2e/tests/dashboards/details/95-edge-cases.spec.ts
Normal file
247
tests/e2e/tests/dashboards/details/95-edge-cases.spec.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createDashboardViaApi,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const VARIABLES_TITLE = 'detail-edge-cases-variables';
|
||||
let apmDashboardId = '';
|
||||
let variablesDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
variablesDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
VARIABLES_TITLE,
|
||||
);
|
||||
seedIds.add(variablesDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function encodeVariables(payload: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
async function gotoDetail(
|
||||
page: Page,
|
||||
id: string,
|
||||
query = '',
|
||||
): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}${query}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Edge Cases', () => {
|
||||
test('TC-01 panels show "No Data" for a far-past time range without pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(
|
||||
page,
|
||||
apmDashboardId,
|
||||
'?startTime=1672531200000&endTime=1672531260000',
|
||||
);
|
||||
|
||||
// The dashboard chrome must render with the far-past range applied:
|
||||
// breadcrumb resolves the dashboard title, panel headers render, and the
|
||||
// time-range textbox reflects the URL.
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// known behaviour: with no variable values resolvable in the far-past
|
||||
// window, APM panels stay in a waiting-on-variable state and never
|
||||
// render the uplot "No Data" overlay. The contract this TC really
|
||||
// guards is that the page does not throw — assert no client-side
|
||||
// pageerror was raised.
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 nonexistent dashboard ID handled gracefully', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
|
||||
// The chrome (sidebar logo) must always render, regardless of whether
|
||||
// the app redirects to /dashboard or shows an in-place error shell.
|
||||
// The bogus-id breadcrumb must never resolve.
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon nonexistent-id-99999/,
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 sidebar nav still works after hitting a nonexistent dashboard URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 variable URL deep-link survives hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const deepLink = `?variables=${encodeVariables({ q_env: 'otel-demo' })}`;
|
||||
await gotoDetail(page, variablesDashboardId, deepLink);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${VARIABLES_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('$q_env', { exact: true })).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
|
||||
// Dropdown index — selects (in DOM order): 0=cu_single, 1=cu_env_all,
|
||||
// 2=cu_services, 3=q_env, 4=q_service, 5=d_namespace.
|
||||
const qEnv = page.getByTestId('variable-select').nth(3);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a single broken time range does not crash the dashboard canvas', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
// known behaviour: the app may either reject a swapped range
|
||||
// client-side or render error states per-panel — either way, the
|
||||
// dashboard chrome and at least one panel header must still render.
|
||||
await gotoDetail(page, apmDashboardId, '?startTime=999999&endTime=999998');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-06 sidebar Dashboards link from detail page navigates to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-07 a 200-character dashboard name renders without breaking layout', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const longName = `LongName-${'x'.repeat(190)}`;
|
||||
const id = await createDashboardViaApi(page, longName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${longName.slice(0, 30)}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
// The toolbar must still render — long titles cannot push the toolbar
|
||||
// off-screen or unmount it. Scope to `.right-section` because empty
|
||||
// dashboards render an onboarding canvas with duplicate testids.
|
||||
const toolbar = page.locator('.dashboard-details .right-section');
|
||||
await expect(toolbar.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(toolbar.getByTestId('add-panel-header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 special characters in the dashboard name round-trip via URL and breadcrumb', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
|
||||
const id = await createDashboardViaApi(page, trickyName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
// The full title must round-trip through the breadcrumb without HTML
|
||||
// entity mangling (`&`, `"` are bugs we'd want to catch).
|
||||
await expect(
|
||||
page.getByText(trickyName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
// document.title is set from the dashboard name — confirm it is intact.
|
||||
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
|
||||
});
|
||||
});
|
||||
@@ -1,326 +0,0 @@
|
||||
"""Tests for resource-level FGA on role endpoints.
|
||||
|
||||
Validates that a custom role with specific role permissions gets exactly
|
||||
the access it was granted — read/list allowed, create/update/delete forbidden
|
||||
until explicitly granted, and revocation removes access.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import (
|
||||
ROLES_BASE,
|
||||
create_custom_role,
|
||||
delete_custom_role,
|
||||
find_role_by_name,
|
||||
object_group,
|
||||
patch_role_objects,
|
||||
)
|
||||
|
||||
ROLE_FGA_CUSTOM_ROLE_NAME = "role-fga-readonly"
|
||||
ROLE_FGA_CUSTOM_USER_EMAIL = "customrole+rolefga@integration.test"
|
||||
ROLE_FGA_CUSTOM_USER_PASSWORD = "password123Z$"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Apply license (required for custom role CRUD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Create custom role + user with read/list on roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_custom_role_for_role_fga(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create the custom role.
|
||||
role_id = create_custom_role(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Grant read on role instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
additions=[
|
||||
object_group("role", "role", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on role collection.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("role", "role", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Create the custom-role user: invite as VIEWER, activate, change role.
|
||||
user_id = create_active_user(
|
||||
signoz,
|
||||
admin_token,
|
||||
email=ROLE_FGA_CUSTOM_USER_EMAIL,
|
||||
role="VIEWER",
|
||||
password=ROLE_FGA_CUSTOM_USER_PASSWORD,
|
||||
name="role-fga-test-user",
|
||||
)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Read-only access: allowed operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_readonly_allowed_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
target_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# List roles.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"list roles: {resp.text}"
|
||||
|
||||
# Get role.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get role: {resp.text}"
|
||||
|
||||
# Get objects for role.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}/relations/read/objects"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get role objects: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Read-only access: forbidden operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_readonly_forbidden_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
target_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Create role — forbidden.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": "role-fga-should-fail"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Patch role — forbidden.
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}"),
|
||||
json={"description": "role-fga-renamed"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"patch role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Patch objects — forbidden.
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}/relations/read/objects"),
|
||||
json={"additions": [object_group("metaresource", "dashboard", ["*"])]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"patch objects: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Delete role — forbidden (cannot delete managed role, but auth check comes first).
|
||||
# Use the custom role itself as target (non-managed, but user lacks delete permission).
|
||||
custom_role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{custom_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"delete role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Grant write permissions, verify access opens up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_grant_write_permissions(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Grant create, update, delete on roles.
|
||||
for verb in ("create", "update", "delete"):
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
verb,
|
||||
additions=[object_group("role", "role", ["*"])],
|
||||
)
|
||||
|
||||
custom_token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Create role — now allowed.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": "role-fga-write-test"},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, f"create role: {resp.text}"
|
||||
new_role_id = resp.json()["data"]["id"]
|
||||
|
||||
# Patch role — now allowed.
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{new_role_id}"),
|
||||
json={"description": "role-fga-write-renamed"},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"patch role: {resp.text}"
|
||||
|
||||
# Delete role — now allowed.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{new_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete role: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Revoke read/list → verify access lost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_revoke_read_permissions(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
target_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Revoke read.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
deletions=[object_group("role", "role", ["*"])],
|
||||
)
|
||||
|
||||
# Revoke list.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
deletions=[object_group("role", "role", ["*"])],
|
||||
)
|
||||
|
||||
custom_token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# List roles — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"list roles after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Get role — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"get role after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Clean up: delete custom role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_fga_cleanup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
user = find_user_by_email(signoz, admin_token, ROLE_FGA_CUSTOM_USER_EMAIL)
|
||||
|
||||
# Remove the custom role from the user first.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
roles = resp.json()["data"]
|
||||
custom_entry = next((r for r in roles if r["name"] == ROLE_FGA_CUSTOM_ROLE_NAME), None)
|
||||
if custom_entry is not None:
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles/{custom_entry['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {resp.text}"
|
||||
|
||||
delete_custom_role(signoz, admin_token, role_id)
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Tests for resource-level FGA on service account endpoints.
|
||||
|
||||
Validates that a custom role with specific SA permissions gets exactly
|
||||
the access it was granted, and that:
|
||||
- SA role assignment requires BOTH serviceaccount:attach AND role:attach.
|
||||
- SA role removal requires BOTH serviceaccount:detach AND role:detach.
|
||||
- Factor API key creation requires factor-api-key:create AND serviceaccount:attach.
|
||||
- Factor API key revocation requires factor-api-key:delete AND serviceaccount:detach.
|
||||
the access it was granted, and that SA role assignment requires BOTH
|
||||
serviceaccount:attach AND role:attach.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
@@ -83,36 +80,14 @@ def test_create_custom_role_readonly_sa(
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on serviceaccount (now on the serviceaccount type directly).
|
||||
# Grant list on serviceaccount collection.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant read on factor-api-key (needed for listing keys).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on factor-api-key.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
object_group("metaresources", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -228,7 +203,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"delete SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Assign role to SA — forbidden (needs attach on both SA and role).
|
||||
# Assign role to SA — forbidden.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
@@ -237,7 +212,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Remove role from SA — forbidden (needs detach on both SA and role).
|
||||
# Remove role from SA — forbidden.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -245,7 +220,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Create key — forbidden (needs factor-api-key:create + serviceaccount:attach).
|
||||
# Create key — forbidden (needs update).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
json={"name": "fga-key-fail", "expiresAt": 0},
|
||||
@@ -254,7 +229,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create key: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Revoke key — forbidden (needs factor-api-key:delete + serviceaccount:detach).
|
||||
# Revoke key — forbidden (needs update).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys/{key_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -278,14 +253,14 @@ def test_patch_role_add_write_permissions(
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Grant create on serviceaccount (now on serviceaccount type directly).
|
||||
# Grant create on collection.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"create",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
object_group("metaresources", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -311,44 +286,6 @@ def test_patch_role_add_write_permissions(
|
||||
],
|
||||
)
|
||||
|
||||
# Grant factor-api-key create/delete + serviceaccount attach/detach for key operations.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"create",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"delete",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"detach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Create SA — now allowed.
|
||||
@@ -370,7 +307,7 @@ def test_patch_role_add_write_permissions(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"update SA: {resp.text}"
|
||||
|
||||
# Create key — now allowed (factor-api-key:create + serviceaccount:attach).
|
||||
# Create key — now allowed (update permission covers key create).
|
||||
key_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}/keys"),
|
||||
json={"name": "fga-write-key", "expiresAt": 0},
|
||||
@@ -380,7 +317,7 @@ def test_patch_role_add_write_permissions(
|
||||
assert key_resp.status_code == HTTPStatus.CREATED, f"create key: {key_resp.text}"
|
||||
new_key_id = key_resp.json()["data"]["id"]
|
||||
|
||||
# Revoke key — now allowed (factor-api-key:delete + serviceaccount:detach).
|
||||
# Revoke key — now allowed (update permission covers key revoke).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}/keys/{new_key_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -396,7 +333,7 @@ def test_patch_role_add_write_permissions(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete SA: {resp.text}"
|
||||
|
||||
# Role assignment still forbidden (has attach on SA but not on role).
|
||||
# Role assignment still forbidden (no attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
@@ -405,7 +342,6 @@ def test_patch_role_add_write_permissions(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Role removal still forbidden (has detach on SA but not on role).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -415,7 +351,7 @@ def test_patch_role_add_write_permissions(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Dual-attach: SA attach only (no role attach) → assign forbidden
|
||||
# 6. Dual-attach: SA attach only (no role attach) → forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -425,10 +361,21 @@ def test_attach_with_only_sa_attach_forbidden(
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# SA attach already granted from previous test; role attach not yet granted.
|
||||
# Grant attach on serviceaccount only.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Assign role — forbidden (has SA attach, missing role attach).
|
||||
@@ -440,36 +387,17 @@ def test_attach_with_only_sa_attach_forbidden(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign with only SA attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Dual-detach: SA detach only (no role detach) → remove forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detach_with_only_sa_detach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# SA detach already granted from test_patch_role_add_write_permissions;
|
||||
# role detach not yet granted.
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Remove role — forbidden (has SA detach, missing role detach).
|
||||
# Remove role — forbidden (CheckAll: role attach group fails).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only SA detach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only SA attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Dual-attach: role attach only (no SA attach) → assign forbidden
|
||||
# 7. Dual-attach: role attach only (no SA attach) → forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -504,49 +432,21 @@ def test_attach_with_only_role_attach_forbidden(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign with only role attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Dual-detach: role detach only (no SA detach) → remove forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detach_with_only_role_detach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Remove SA detach, grant role detach.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"detach",
|
||||
additions=[object_group("role", "role", ["*"])],
|
||||
deletions=[object_group("serviceaccount", "serviceaccount", ["*"])],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Remove role — forbidden (SA detach check fails).
|
||||
# Remove role — forbidden (CheckAll: SA attach group fails).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only role detach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only role attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Both attach + detach → assign and remove succeed
|
||||
# 8. Dual-attach: both SA + role attach → succeeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_detach_with_both_permissions_succeeds(
|
||||
def test_attach_with_both_permissions_succeeds(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
@@ -555,7 +455,7 @@ def test_attach_detach_with_both_permissions_succeeds(
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
# Add back SA attach and SA detach (role attach/detach already present from previous tests).
|
||||
# Add back SA attach (role attach already present from previous test).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
@@ -565,15 +465,6 @@ def test_attach_detach_with_both_permissions_succeeds(
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"detach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
@@ -589,17 +480,17 @@ def test_attach_detach_with_both_permissions_succeeds(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"assign with both attach: {resp.text}"
|
||||
|
||||
# Remove the editor role — should succeed (both SA detach + role detach).
|
||||
# Remove the editor role — should succeed (CheckAll: both groups pass).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{editor_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove with both detach: {resp.text}"
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove with both attach: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Revoke read/list → verify access lost
|
||||
# 9. Revoke read/list → verify access lost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -623,14 +514,14 @@ def test_remove_read_permissions_revokes_access(
|
||||
],
|
||||
)
|
||||
|
||||
# Revoke list (now on serviceaccount type directly).
|
||||
# Revoke list.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
deletions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
object_group("metaresources", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -654,7 +545,7 @@ def test_remove_read_permissions_revokes_access(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. Clean up: delete custom role
|
||||
# 10. Clean up: delete custom role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user