Compare commits

..

21 Commits

Author SHA1 Message Date
nityanandagohain
282b6fdef1 fix: address comments 2026-05-07 20:09:11 +05:30
Nityananda Gohain
9b64bb2fc0 Merge branch 'main' into issue_4203 2026-05-04 11:12:10 +05:30
nityanandagohain
b818ff5fc4 fix: address comments 2026-04-29 17:19:19 +05:30
nityanandagohain
e7d729ab5d Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-29 16:51:49 +05:30
Nityananda Gohain
ed812ad1c8 Merge branch 'main' into issue_4203 2026-04-24 11:25:38 +05:30
nityanandagohain
3b82c2ce43 fix: restrict merging to only span data 2026-04-24 11:25:11 +05:30
nityanandagohain
214980ddad Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-24 10:22:33 +05:30
nityanandagohain
a7b69a2678 fix: py-fmt 2026-04-21 12:13:47 +05:30
nityanandagohain
73c82f50a9 Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-21 11:49:52 +05:30
nityanandagohain
2593c5eb91 fix: linting issues 2026-04-13 15:44:43 +05:30
Nityananda Gohain
b6b2d36baa Merge branch 'main' into issue_4203 2026-04-10 17:15:08 +05:30
nityanandagohain
a444a039f9 Merge remote-tracking branch 'origin/issue_4203' into issue_4203 2026-04-10 17:13:22 +05:30
nityanandagohain
bfb050ec17 fix: add changes 2026-04-10 16:57:50 +05:30
nityanandagohain
ff3e87f70c Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-09 21:29:11 +05:30
Nityananda Gohain
9ac02ebe00 Merge branch 'main' into issue_4203 2026-03-25 15:50:04 +05:30
nityanandagohain
fbdd0bebbc Merge remote-tracking branch 'origin/main' into issue_4203 2026-03-25 15:21:52 +05:30
nityanandagohain
b2245b48fe fix: retain existing behaviour 2026-03-23 11:03:34 +05:30
Nityananda Gohain
87e654fc73 chore: add comment
Co-authored-by: Tushar Vats <tushar@signoz.io>
2026-03-18 16:54:09 +05:30
nityanandagohain
0ee31ce440 chore: fix tests 2026-03-17 18:16:51 +05:30
nityanandagohain
63e681b87b chore: add integration tests 2026-03-17 15:38:00 +05:30
nityanandagohain
28375c8c1e chore: send all data for trace list api 2026-03-13 19:31:59 +05:30
482 changed files with 11381 additions and 34886 deletions

1
.github/CODEOWNERS vendored
View File

@@ -107,7 +107,6 @@ go.mod @therealpandey
/pkg/modules/organization/ @therealpandey
/pkg/modules/authdomain/ @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
# IdentN Owners

View File

@@ -102,20 +102,3 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
authz:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)

View File

@@ -63,6 +63,46 @@ jobs:
uses: actions/checkout@v4
- name: run
run: bash frontend/scripts/validate-md-languages.sh
authz:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v5
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: deps-install
working-directory: ./frontend
run: |
yarn install
- name: uv-install
uses: astral-sh/setup-uv@v5
- name: uv-deps
working-directory: ./tests/integration
run: |
uv sync
- name: setup-test
run: |
make py-test-setup
- name: generate
working-directory: ./frontend
run: |
yarn generate:permissions-type
- name: teardown-test
if: always()
run: |
make py-test-teardown
- name: validate
run: |
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
exit 1
fi
openapi:
if: |
github.event_name == 'merge_group' ||

View File

@@ -1,117 +0,0 @@
package cmd
import (
"bytes"
"context"
"os"
"sort"
"text/template"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/spf13/cobra"
)
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
export default {
status: 'success',
data: {
resources: [
{{- range .Resources }}
{
kind: '{{ .Kind }}',
type: '{{ .Type }}',
},
{{- end }}
],
relations: {
{{- range .Relations }}
{{ .Verb }}: [{{ range $i, $t := .Types }}{{ if $i }}, {{ end }}'{{ $t }}'{{ end }}],
{{- end }}
},
},
} as const;
`))
type permissionsTypeRelation struct {
Verb string
Types []string
}
type permissionsTypeResource struct {
Kind string
Type string
}
type permissionsTypeData struct {
Resources []permissionsTypeResource
Relations []permissionsTypeRelation
}
func registerGenerateAuthz(parentCmd *cobra.Command) {
authzCmd := &cobra.Command{
Use: "authz",
Short: "Generate authz permissions for the frontend",
RunE: func(currCmd *cobra.Command, args []string) error {
return runGenerateAuthz(currCmd.Context())
},
}
parentCmd.AddCommand(authzCmd)
}
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.ResourceMetaResourcesRole).String(): true,
}
allowedTypes := map[string]bool{}
refs := registry.ResourceRefs()
resources := make([]permissionsTypeResource, 0, len(refs))
for _, ref := range refs {
if !allowedResources[ref.String()] {
continue
}
allowedTypes[ref.Type.StringValue()] = true
resources = append(resources, permissionsTypeResource{
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
})
}
typesByVerb := registry.TypesByVerb()
verbs := make([]coretypes.Verb, 0, len(typesByVerb))
for verb := range typesByVerb {
verbs = append(verbs, verb)
}
sort.Slice(verbs, func(i, j int) bool { return verbs[i].StringValue() < verbs[j].StringValue() })
relations := make([]permissionsTypeRelation, 0, len(verbs))
for _, verb := range verbs {
types := make([]string, 0, len(typesByVerb[verb]))
for _, t := range typesByVerb[verb] {
if !allowedTypes[t.StringValue()] {
continue
}
types = append(types, t.StringValue())
}
relations = append(relations, permissionsTypeRelation{
Verb: verb.StringValue(),
Types: types,
})
}
var buf bytes.Buffer
if err := permissionsTypeTemplate.Execute(&buf, permissionsTypeData{Resources: resources, Relations: relations}); err != nil {
return err
}
return os.WriteFile(permissionsTypePath, buf.Bytes(), 0o600)
}

View File

@@ -92,13 +92,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)

View File

@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)

View File

@@ -16,7 +16,6 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
}
registerGenerateOpenAPI(generateCmd)
registerGenerateAuthz(generateCmd)
parentCmd.AddCommand(generateCmd)
}

View File

@@ -13,8 +13,6 @@ global:
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
# mcp_url: <unset>
# the url of the SigNoz AI Assistant server. when unset, the AI Assistant is hidden in the frontend.
# ai_assistant_url: <unset>
##################### Version #####################
version:
@@ -428,4 +426,4 @@ authz:
provider: openfga
openfga:
# maximum tuples allowed per openfga write operation.
max_tuples_per_write: 300
max_tuples_per_write: 100

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.122.0
image: signoz/signoz:v0.121.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.122.0
image: signoz/signoz:v0.121.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.122.0}
image: signoz/signoz:${VERSION:-v0.121.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.122.0}
image: signoz/signoz:${VERSION:-v0.121.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -96,122 +96,6 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
- discord_configs
- required:
- email_configs
- required:
- incidentio_configs
- required:
- pagerduty_configs
- required:
- slack_configs
- required:
- webhook_configs
- required:
- opsgenie_configs
- required:
- wechat_configs
- required:
- pushover_configs
- required:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
- mattermost_configs
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
required:
- name
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -249,10 +133,6 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
@@ -265,15 +145,8 @@ components:
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
- saml
- email_password
- oidc
type: string
AuthtypesAuthNProviderInfo:
properties:
relayStatePath:
@@ -296,7 +169,7 @@ components:
AuthtypesCallbackAuthNSupport:
properties:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
url:
type: string
type: object
@@ -304,23 +177,62 @@ components:
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
createdAt:
format: date-time
type: string
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
id:
type: string
name:
type: string
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
AuthtypesGettableObjects:
properties:
resource:
$ref: '#/components/schemas/AuthtypesResource'
selectors:
items:
type: string
type: array
required:
- resource
- selectors
type: object
AuthtypesGettableResources:
properties:
relations:
additionalProperties:
items:
type: string
type: array
nullable: true
type: object
resources:
items:
$ref: '#/components/schemas/AuthtypesResource'
type: array
required:
- resources
- relations
type: object
AuthtypesGettableToken:
properties:
accessToken:
@@ -337,9 +249,9 @@ components:
authorized:
type: boolean
object:
$ref: '#/components/schemas/CoretypesObject'
$ref: '#/components/schemas/AuthtypesObject'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
type: string
required:
- relation
- object
@@ -387,6 +299,16 @@ components:
issuerAlias:
type: string
type: object
AuthtypesObject:
properties:
resource:
$ref: '#/components/schemas/AuthtypesResource'
selector:
type: string
required:
- resource
- selector
type: object
AuthtypesOrgSessionContext:
properties:
authNSupport:
@@ -401,7 +323,23 @@ components:
AuthtypesPasswordAuthNSupport:
properties:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
type: object
AuthtypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
nullable: true
type: array
required:
- additions
- deletions
type: object
AuthtypesPatchableRole:
properties:
@@ -440,15 +378,16 @@ components:
refreshToken:
type: string
type: object
AuthtypesRelation:
enum:
- create
- read
- update
- delete
- list
- assignee
type: string
AuthtypesResource:
properties:
name:
type: string
type:
type: string
required:
- name
- type
type: object
AuthtypesRole:
properties:
createdAt:
@@ -512,14 +451,14 @@ components:
AuthtypesTransaction:
properties:
object:
$ref: '#/components/schemas/CoretypesObject'
$ref: '#/components/schemas/AuthtypesObject'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
type: string
required:
- relation
- object
type: object
AuthtypesUpdatableAuthDomain:
AuthtypesUpdateableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
@@ -2149,64 +2088,6 @@ components:
to_user:
type: string
type: object
CoretypesObject:
properties:
resource:
$ref: '#/components/schemas/CoretypesResourceRef'
selector:
type: string
required:
- resource
- selector
type: object
CoretypesObjectGroup:
properties:
resource:
$ref: '#/components/schemas/CoretypesResourceRef'
selectors:
items:
type: string
type: array
required:
- resource
- selectors
type: object
CoretypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
required:
- additions
- deletions
type: object
CoretypesResourceRef:
properties:
kind:
type: string
type:
$ref: '#/components/schemas/CoretypesType'
required:
- type
- kind
type: object
CoretypesType:
enum:
- user
- serviceaccount
- anonymous
- role
- organization
- metaresource
- metaresources
type: string
DashboardtypesDashboard:
properties:
createdAt:
@@ -2482,9 +2363,6 @@ components:
type: object
GlobaltypesConfig:
properties:
ai_assistant_url:
nullable: true
type: string
external_url:
type: string
identN:
@@ -2498,7 +2376,6 @@ components:
- external_url
- ingestion_url
- mcp_url
- ai_assistant_url
type: object
GlobaltypesIdentNConfig:
properties:
@@ -2547,8 +2424,7 @@ components:
format: double
type: number
meta:
additionalProperties:
type: string
additionalProperties: {}
nullable: true
type: object
status:
@@ -2598,103 +2474,6 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesNodeCondition:
enum:
- ready
- not_ready
- no_data
type: string
InframonitoringtypesNodeCountsByReadiness:
properties:
notReady:
type: integer
ready:
type: integer
required:
- ready
- notReady
type: object
InframonitoringtypesNodeRecord:
properties:
condition:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
meta:
additionalProperties:
type: string
nullable: true
type: object
nodeCPU:
format: double
type: number
nodeCPUAllocatable:
format: double
type: number
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
nodeMemory:
format: double
type: number
nodeMemoryAllocatable:
format: double
type: number
nodeName:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
- nodeMemoryAllocatable
- meta
type: object
InframonitoringtypesNodes:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
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
InframonitoringtypesPodCountsByPhase:
properties:
failed:
type: integer
pending:
type: integer
running:
type: integer
succeeded:
type: integer
unknown:
type: integer
required:
- pending
- running
- succeeded
- failed
- unknown
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -2702,15 +2481,18 @@ components:
- succeeded
- failed
- unknown
- no_data
- ""
type: string
InframonitoringtypesPodRecord:
properties:
failedPodCount:
type: integer
meta:
additionalProperties:
type: string
additionalProperties: {}
nullable: true
type: object
pendingPodCount:
type: integer
podAge:
format: int64
type: integer
@@ -2723,8 +2505,6 @@ components:
podCPURequest:
format: double
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podMemory:
format: double
type: number
@@ -2738,6 +2518,12 @@ components:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podUID:
type: string
runningPodCount:
type: integer
succeededPodCount:
type: integer
unknownPodCount:
type: integer
required:
- podUID
- podCPU
@@ -2747,7 +2533,11 @@ components:
- podMemoryRequest
- podMemoryLimit
- podPhase
- podCountsByPhase
- pendingPodCount
- runningPodCount
- succeededPodCount
- failedPodCount
- unknownPodCount
- podAge
- meta
type: object
@@ -2801,32 +2591,6 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableNodes:
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
InframonitoringtypesPostablePods:
properties:
end:
@@ -5436,9 +5200,6 @@ components:
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
@@ -5819,6 +5580,35 @@ paths:
summary: Check permissions
tags:
- authz
/api/v1/authz/resources:
get:
deprecated: false
description: Gets all the available resources
operationId: AuthzResources
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableResources'
status:
type: string
required:
- status
- data
type: object
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Get resources
tags:
- authz
/api/v1/channels:
get:
deprecated: false
@@ -5875,7 +5665,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
$ref: '#/components/schemas/ConfigReceiver'
responses:
"201":
content:
@@ -7154,20 +6944,20 @@ paths:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: Created
description: OK
"400":
content:
application/json:
@@ -7252,63 +7042,6 @@ paths:
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
@@ -7323,7 +7056,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
responses:
"204":
description: No Content
@@ -9042,7 +8775,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
$ref: '#/components/schemas/AuthtypesGettableObjects'
type: array
status:
type: string
@@ -9115,7 +8848,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CoretypesPatchableObjects'
$ref: '#/components/schemas/AuthtypesPatchableObjects'
responses:
"204":
content:
@@ -11731,83 +11464,12 @@ paths:
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/nodes:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
CPU usage, CPU allocatable, memory working set, memory allocatable, per-group
nodeCountsByReadiness ({ ready, notReady } from each node''s latest k8s.node.condition_ready
in the window) and per-group podCountsByPhase ({ pending, running, succeeded,
failed, unknown } for pods scheduled on the listed nodes). Each node includes
metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is
''list'' for the default k8s.node.name grouping (each row is one node with
its current condition string: ready / not_ready / no_data) or ''grouped_list''
for custom groupBy keys (each row aggregates nodes in the group; condition
stays no_data). Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, 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
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
as a sentinel when no data is available for that field.'
operationId: ListNodes
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesNodes'
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 Nodes for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/pods:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes pods with key metrics:
CPU usage, CPU request/limit utilization, memory working set, memory request/limit
utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data),
utilization, current pod phase (pending/running/succeeded/failed/unknown),
and pod age (ms since start time). Each pod includes metadata attributes (namespace,
node, workload owner such as deployment/statefulset/daemonset/job/cronjob,
cluster). Supports filtering via a filter expression, custom groupBy to aggregate
@@ -11815,13 +11477,13 @@ paths:
cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit.
The response type is ''list'' for the default k8s.pod.uid grouping (each row
is one pod with its current phase) or ''grouped_list'' for custom groupBy
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
{ pending, running, succeeded, failed, unknown } derived from each pod''s
latest phase in the window). Also reports missing required metrics and whether
the requested time range falls before the data retention boundary. Numeric
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
for that field.'
keys (each row aggregates pods in the group with per-phase counts: pendingPodCount,
runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived
from each pod''s latest phase in the window). Also reports missing required
metrics and whether the requested time range falls before the data retention
boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory,
podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no
data is available for that field.'
operationId: ListPods
requestBody:
content:

View File

@@ -2,6 +2,7 @@ package openfgaauthz
import (
"context"
"slices"
"github.com/SigNoz/signoz/ee/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/authz"
@@ -12,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
@@ -25,19 +25,19 @@ type provider struct {
openfgaServer *openfgaserver.Server
licensing licensing.Licensing
store authtypes.RoleStore
registry *authtypes.Registry
registry []authz.RegisterTypeable
settings factory.ScopedProviderSettings
onBeforeRoleDelete []authz.OnBeforeRoleDelete
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry *authtypes.Registry) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, openfgaDataStore, licensing, onBeforeRoleDelete, registry)
})
}
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry *authtypes.Registry) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore, registry)
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore)
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
if err != nil {
return nil, err
@@ -74,11 +74,11 @@ func (provider *provider) Stop(ctx context.Context) error {
return provider.openfgaServer.Stop(ctx)
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, roleSelectors []coretypes.Selector) error {
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreation(ctx, claims, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, roleSelectors []coretypes.Selector) error {
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, roleSelectors)
}
@@ -108,7 +108,7 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
return results, nil
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
}
@@ -159,10 +159,16 @@ func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.U
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
tuples := make([]*openfgav1.TupleKey, 0)
grantTuples := provider.getManagedRoleGrantTuples(orgID, userID)
grantTuples, err := provider.getManagedRoleGrantTuples(orgID, userID)
if err != nil {
return err
}
tuples = append(tuples, grantTuples...)
managedRoleTuples := provider.getManagedRoleTransactionTuples(orgID)
managedRoleTuples, err := provider.getManagedRoleTransactionTuples(orgID)
if err != nil {
return err
}
tuples = append(tuples, managedRoleTuples...)
return provider.Write(ctx, tuples, nil)
@@ -202,7 +208,21 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
resources := make([]*authtypes.Resource, 0)
for _, register := range provider.registry {
for _, typeable := range register.MustGetTypeables() {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
}
for _, typeable := range provider.MustGetTypeables() {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -213,16 +233,16 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return nil, err
}
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
objects := make([]*authtypes.Object, 0)
for _, objectType := range provider.getUniqueTypes() {
if !slices.Contains(authtypes.TypeableRelations[objectType], relation) {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.Name, orgID, &authtypes.RelationAssignee),
relation,
objectType,
)
@@ -245,7 +265,7 @@ func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *au
return provider.store.Update(ctx, orgID, role)
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -298,63 +318,84 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, authtypes.TypeableResourcesRoles}
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
tuples := []*openfgav1.TupleKey{}
// Grant the admin role to the user
adminSubject := authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil)
adminTuple := authtypes.NewTuples(
coretypes.NewResourceRole(),
adminSubject := authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil)
adminTuple, err := authtypes.TypeableRole.Tuples(
adminSubject,
authtypes.Relation{Verb: coretypes.VerbAssignee},
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName)},
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, adminTuple...)
// Grant the admin role to the anonymous user
anonymousSubject := authtypes.MustNewSubject(coretypes.NewResourceAnonymous(), coretypes.AnonymousUser.String(), orgID, nil)
anonymousTuple := authtypes.NewTuples(
coretypes.NewResourceRole(),
anonymousSubject := authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
anonymousTuple, err := authtypes.TypeableRole.Tuples(
anonymousSubject,
authtypes.Relation{Verb: coretypes.VerbAssignee},
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAnonymousRoleName)},
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAnonymousRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, anonymousTuple...)
return tuples
return tuples, nil
}
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*openfgav1.TupleKey {
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
transactionsByRole := make(map[string][]*authtypes.Transaction)
for _, register := range provider.registry {
for roleName, txns := range register.MustGetManagedRoleTransactions() {
transactionsByRole[roleName] = append(transactionsByRole[roleName], txns...)
}
}
tuples := make([]*openfgav1.TupleKey, 0)
for roleName, transactions := range provider.registry.ManagedRoleTransactions() {
for roleName, transactions := range transactionsByRole {
for _, txn := range transactions {
resource := coretypes.MustNewResourceFromTypeAndKind(txn.Object.Resource.Type, txn.Object.Resource.Kind)
txnTuples := authtypes.NewTuples(
resource,
typeable := authtypes.MustNewTypeableFromType(txn.Object.Resource.Type, txn.Object.Resource.Name)
txnTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
coretypes.NewResourceRole(),
authtypes.TypeableRole,
roleName,
orgID,
&coretypes.VerbAssignee,
&authtypes.RelationAssignee,
),
txn.Relation,
[]coretypes.Selector{txn.Object.Selector},
[]authtypes.Selector{txn.Object.Selector},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, txnTuples...)
}
}
return tuples
return tuples, nil
}
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
subject := authtypes.MustNewSubject(authtypes.TypeableRole, roleName, orgID, &authtypes.RelationAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
for _, objectType := range provider.getUniqueTypes() {
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
@@ -383,3 +424,28 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
return nil
}
func (provider *provider) getUniqueTypes() []authtypes.Type {
seen := make(map[string]struct{})
uniqueTypes := make([]authtypes.Type, 0)
for _, register := range provider.registry {
for _, typeable := range register.MustGetTypeables() {
typeKey := typeable.Type().StringValue()
if _, ok := seen[typeKey]; ok {
continue
}
seen[typeKey] = struct{}{}
uniqueTypes = append(uniqueTypes, typeable.Type())
}
}
for _, typeable := range provider.MustGetTypeables() {
typeKey := typeable.Type().StringValue()
if _, ok := seen[typeKey]; ok {
continue
}
seen[typeKey] = struct{}{}
uniqueTypes = append(uniqueTypes, typeable.Type())
}
return uniqueTypes
}

View File

@@ -1,6 +1,6 @@
module base
type organization
type organisation
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
@@ -10,14 +10,12 @@ type user
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type serviceaccount
type serviceaccount
relations
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 delete: [user, serviceaccount, role#assignee]
type anonymous
@@ -28,7 +26,6 @@ type role
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type metaresources
relations

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
@@ -34,18 +33,18 @@ func (server *Server) Stop(ctx context.Context) error {
return server.pkgAuthzService.Stop(ctx)
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, _ []coretypes.Selector) error {
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser:
user, err := authtypes.NewSubject(coretypes.NewResourceUser(), claims.UserID, orgID, nil)
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount:
serviceAccount, err := authtypes.NewSubject(coretypes.NewResourceServiceAccount(), claims.ServiceAccountID, orgID, nil)
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
@@ -53,7 +52,10 @@ func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtyp
subject = serviceAccount
}
tupleSlice := authtypes.NewTuples(typeable, subject, relation, selectors, orgID)
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
tuples := make(map[string]*openfgav1.TupleKey, len(tupleSlice))
for idx, tuple := range tupleSlice {
@@ -74,13 +76,16 @@ func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtyp
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subjects are not authorized for requested access")
}
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, _ []coretypes.Selector) error {
subject, err := authtypes.NewSubject(coretypes.NewResourceAnonymous(), coretypes.AnonymousUser.String(), orgID, nil)
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
tupleSlice := authtypes.NewTuples(typeable, subject, relation, selectors, orgID)
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
tuples := make(map[string]*openfgav1.TupleKey, len(tupleSlice))
for idx, tuple := range tupleSlice {
@@ -105,7 +110,7 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
}

View File

@@ -2,7 +2,6 @@ package openfgaserver
import (
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/openfga/openfga/pkg/storage"
@@ -11,11 +10,11 @@ import (
"github.com/openfga/openfga/pkg/storage/sqlite"
)
func NewSQLStore(store sqlstore.SQLStore, config authz.Config) (storage.OpenFGADatastore, error) {
func NewSQLStore(store sqlstore.SQLStore) (storage.OpenFGADatastore, error) {
switch store.BunDB().Dialect().Name().String() {
case "sqlite":
return sqlite.NewWithDB(store.SQLDB(), &sqlcommon.Config{
MaxTuplesPerWriteField: config.OpenFGA.MaxTuplesPerWrite,
MaxTuplesPerWriteField: 100,
MaxTypesPerModelField: 100,
})
case "pg":
@@ -25,7 +24,7 @@ func NewSQLStore(store sqlstore.SQLStore, config authz.Config) (storage.OpenFGAD
}
return postgres.NewWithDB(pgStore.Pool(), nil, &sqlcommon.Config{
MaxTuplesPerWriteField: config.OpenFGA.MaxTuplesPerWrite,
MaxTuplesPerWriteField: 100,
MaxTypesPerModelField: 100,
})
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
@@ -88,7 +88,7 @@ func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
orgIDs := make([]string, len(orgs))
for idx, org := range orgs {
orgIDs[idx] = org.ID.StringValue()
@@ -99,9 +99,9 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
return nil, valuer.UUID{}, err
}
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(id.StringValue()),
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, storableDashboard.OrgID, nil
}
@@ -217,6 +217,28 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAnonymousRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
}

View File

@@ -6,8 +6,6 @@ import (
"io"
"net/http"
"log/slog"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -17,6 +15,7 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"log/slog"
)
func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
@@ -138,3 +137,4 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
aH.QueryRangeV4(w, r)
}
}

View File

@@ -42,8 +42,8 @@ import (
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
@@ -148,7 +148,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
s := &Server{
config: config,
signoz: signoz,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
@@ -317,3 +317,4 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}

View File

@@ -23,11 +23,6 @@
"**/*.md",
"**/*.json",
"src/parser/**",
"src/TraceOperator/parser/**",
".claude",
".opencode",
"dist",
"playwright-report",
".temp_cache"
"src/TraceOperator/parser/**"
]
}

View File

@@ -3,7 +3,6 @@ import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
maxWorkers: '50%',
silent: true,
clearMocks: true,
coverageDirectory: 'coverage',

View File

@@ -23,7 +23,8 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:permissions-type": "node scripts/generate-permissions-type.cjs"
},
"engines": {
"node": ">=22.0.0"
@@ -120,12 +121,10 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",
@@ -233,7 +232,6 @@
"ts-jest": "29.4.6",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",
"vite-plugin-image-optimizer": "2.0.3",
@@ -241,12 +239,10 @@
},
"lint-staged": {
"*.(js|jsx|ts|tsx)": [
"oxlint --fix",
"oxfmt --write",
"sh -c tsgo --noEmit"
],
"*.(js|jsx|ts|tsx|scss|css)": [
"oxlint --fix --quiet --no-error-on-unmatched-pattern",
"oxfmt --write"
],
"*.(scss|css)": [
"stylelint"
]

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const axios = require('axios');
const PERMISSIONS_TYPE_FILE = path.join(
__dirname,
'../src/hooks/useAuthZ/permissions.type.ts',
);
const SIGNOZ_INTEGRATION_IMAGE = 'signoz:integration';
const LOCAL_BACKEND_URL = 'http://localhost:8080';
function log(message) {
console.log(`[generate-permissions-type] ${message}`);
}
function getBackendUrlFromDocker() {
try {
const output = execSync(
`docker ps --filter "ancestor=${SIGNOZ_INTEGRATION_IMAGE}" --format "{{.Ports}}"`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
if (!output) {
return null;
}
const portMatch = output.match(/0\.0\.0\.0:(\d+)->8080\/tcp/);
if (portMatch) {
return `http://localhost:${portMatch[1]}`;
}
const ipv6Match = output.match(/:::(\d+)->8080\/tcp/);
if (ipv6Match) {
return `http://localhost:${ipv6Match[1]}`;
}
} catch (err) {
log(`Warning: Could not get port from docker: ${err.message}`);
}
return null;
}
async function checkBackendHealth(url, maxAttempts = 3, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.get(`${url}/api/v1/health`, {
timeout: 5000,
validateStatus: (status) => status === 200,
});
return true;
} catch (err) {
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
return false;
}
async function discoverBackendUrl() {
const dockerUrl = getBackendUrlFromDocker();
if (dockerUrl) {
log(`Found ${SIGNOZ_INTEGRATION_IMAGE} container, trying ${dockerUrl}...`);
if (await checkBackendHealth(dockerUrl)) {
log(`Backend found at ${dockerUrl} (from py-test-setup)`);
return dockerUrl;
}
log(`Backend at ${dockerUrl} is not responding`);
}
log(`Trying local backend at ${LOCAL_BACKEND_URL}...`);
if (await checkBackendHealth(LOCAL_BACKEND_URL)) {
log(`Backend found at ${LOCAL_BACKEND_URL}`);
return LOCAL_BACKEND_URL;
}
return null;
}
async function fetchResources(backendUrl) {
log('Fetching resources from API...');
const resourcesUrl = `${backendUrl}/api/v1/authz/resources`;
const { data: response } = await axios.get(resourcesUrl);
return response;
}
function transformResponse(apiResponse) {
if (!apiResponse.data) {
throw new Error('Invalid API response: missing data field');
}
const { resources, relations } = apiResponse.data;
return {
status: apiResponse.status || 'success',
data: {
resources: resources,
relations: relations,
},
};
}
function generateTypeScriptFile(data) {
const resourcesStr = data.data.resources
.map(
(r) =>
`\t\t\t{\n\t\t\t\tname: '${r.name}',\n\t\t\t\ttype: '${r.type}',\n\t\t\t},`,
)
.join('\n');
const relationsStr = Object.entries(data.data.relations)
.map(
([type, relations]) =>
`\t\t\t${type}: [${relations.map((r) => `'${r}'`).join(', ')}],`,
)
.join('\n');
return `// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
export default {
\tstatus: '${data.status}',
\tdata: {
\t\tresources: [
${resourcesStr}
\t\t],
\t\trelations: {
${relationsStr}
\t\t},
\t},
} as const;
`;
}
async function main() {
try {
log('Starting permissions type generation...');
const backendUrl = await discoverBackendUrl();
if (!backendUrl) {
console.error('\n' + '='.repeat(80));
console.error('ERROR: No running SigNoz backend found!');
console.error('='.repeat(80));
console.error(
'\nThe permissions type generator requires a running SigNoz backend.',
);
console.error('\nFor local development, start the backend with:');
console.error(' make go-run-enterprise');
console.error(
'\nFor CI or integration testing, start the test environment with:',
);
console.error(' make py-test-setup');
console.error(
'\nIf running in CI and seeing this error, check that the py-test-setup',
);
console.error('step completed successfully before this step runs.');
console.error('='.repeat(80) + '\n');
process.exit(1);
}
log('Fetching resources...');
const apiResponse = await fetchResources(backendUrl);
log('Transforming response...');
const transformed = transformResponse(apiResponse);
log('Generating TypeScript file...');
const content = generateTypeScriptFile(transformed);
log(`Writing to ${PERMISSIONS_TYPE_FILE}...`);
fs.writeFileSync(PERMISSIONS_TYPE_FILE, content, 'utf8');
const rootDir = path.join(__dirname, '../..');
const relativePath = path.relative(
path.join(rootDir, 'frontend'),
PERMISSIONS_TYPE_FILE,
);
log('Linting generated file...');
execSync(`cd frontend && yarn oxlint ${relativePath}`, {
cwd: rootDir,
stdio: 'inherit',
});
log('Successfully generated permissions.type.ts');
} catch (error) {
log(`Error: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { main };

View File

@@ -65,13 +65,6 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
),
);
export const UsageExplorerPage = Loadable(
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
);

View File

@@ -48,7 +48,6 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -139,9 +138,6 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// V3 trace details is gated until release: /trace serves V2 (public),
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -149,13 +145,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.SETTINGS,
exact: false,

View File

@@ -19,11 +19,9 @@ import type {
import type {
AuthtypesPostableAuthDomainDTO,
AuthtypesUpdatableAuthDomainDTO,
CreateAuthDomain201,
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
ListAuthDomains200,
RenderErrorResponseDTO,
UpdateAuthDomainPathParameters,
@@ -126,7 +124,7 @@ export const createAuthDomain = (
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain201>({
return GeneratedAPIInstance<CreateAuthDomain200>({
url: `/api/v1/domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -279,122 +277,19 @@ export const useDeleteAuthDomain = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns an auth domain by ID
* @summary Get auth domain by ID
*/
export const getAuthDomain = (
{ id }: GetAuthDomainPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
method: 'GET',
signal,
});
};
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetAuthDomainQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthDomain>>> = ({
signal,
}) => getAuthDomain({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAuthDomainQueryResult = NonNullable<
Awaited<ReturnType<typeof getAuthDomain>>
>;
export type GetAuthDomainQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get auth domain by ID
*/
export function useGetAuthDomain<
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAuthDomainQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get auth domain by ID
*/
export const invalidateGetAuthDomain = async (
queryClient: QueryClient,
{ id }: GetAuthDomainPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAuthDomainQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates an auth domain
* @summary Update auth domain
*/
export const updateAuthDomain = (
{ id }: UpdateAuthDomainPathParameters,
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableAuthDomainDTO,
data: authtypesUpdateableAuthDomainDTO,
});
};
@@ -407,7 +302,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
>;
@@ -416,7 +311,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
> => {
@@ -433,7 +328,7 @@ export const getUpdateAuthDomainMutationOptions = <
Awaited<ReturnType<typeof updateAuthDomain>>,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -448,7 +343,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAuthDomain>>
>;
export type UpdateAuthDomainMutationBody =
BodyType<AuthtypesUpdatableAuthDomainDTO>;
BodyType<AuthtypesUpdateableAuthDomainDTO>;
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -463,7 +358,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
>;
@@ -472,7 +367,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
> => {

View File

@@ -4,16 +4,23 @@
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
AuthtypesTransactionDTO,
AuthzCheck200,
AuthzResources200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -103,3 +110,88 @@ export const useAuthzCheck = <
return useMutation(mutationOptions);
};
/**
* Gets all the available resources
* @summary Get resources
*/
export const authzResources = (signal?: AbortSignal) => {
return GeneratedAPIInstance<AuthzResources200>({
url: `/api/v1/authz/resources`,
method: 'GET',
signal,
});
};
export const getAuthzResourcesQueryKey = () => {
return [`/api/v1/authz/resources`] as const;
};
export const getAuthzResourcesQueryOptions = <
TData = Awaited<ReturnType<typeof authzResources>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getAuthzResourcesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof authzResources>>> = ({
signal,
}) => authzResources(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type AuthzResourcesQueryResult = NonNullable<
Awaited<ReturnType<typeof authzResources>>
>;
export type AuthzResourcesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get resources
*/
export function useAuthzResources<
TData = Awaited<ReturnType<typeof authzResources>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getAuthzResourcesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get resources
*/
export const invalidateAuthzResources = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getAuthzResourcesQueryKey() },
options,
);
return queryClient;
};

View File

@@ -18,7 +18,6 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostableChannelDTO,
ConfigReceiverDTO,
CreateChannel201,
DeleteChannelByIDPathParameters,
@@ -123,14 +122,14 @@ export const invalidateListChannels = async (
* @summary Create notification channel
*/
export const createChannel = (
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
configReceiverDTO: BodyType<ConfigReceiverDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateChannel201>({
url: `/api/v1/channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostableChannelDTO,
data: configReceiverDTO,
signal,
});
};
@@ -142,13 +141,13 @@ export const getCreateChannelMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
> => {
const mutationKey = ['createChannel'];
@@ -162,7 +161,7 @@ export const getCreateChannelMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createChannel>>,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
{ data: BodyType<ConfigReceiverDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -175,8 +174,7 @@ export const getCreateChannelMutationOptions = <
export type CreateChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createChannel>>
>;
export type CreateChannelMutationBody =
BodyType<AlertmanagertypesPostableChannelDTO>;
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -189,13 +187,13 @@ export const useCreateChannel = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
> => {
const mutationOptions = getCreateChannelMutationOptions(options);

View File

@@ -13,10 +13,8 @@ import type {
import type {
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
ListHosts200,
ListNodes200,
ListPods200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -109,91 +107,7 @@ export const useListHosts = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, 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 (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Nodes for Infra Monitoring
*/
export const listNodes = (
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNodes200>({
url: `/api/v2/infra_monitoring/nodes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableNodesDTO,
signal,
});
};
export const getListNodesMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationKey = ['listNodes'];
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 listNodes>>,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
> = (props) => {
const { data } = props ?? {};
return listNodes(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListNodesMutationResult = NonNullable<
Awaited<ReturnType<typeof listNodes>>
>;
export type ListNodesMutationBody =
BodyType<InframonitoringtypesPostableNodesDTO>;
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Nodes for Infra Monitoring
*/
export const useListNodes = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationOptions = getListNodesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring
*/
export const listPods = (

View File

@@ -18,9 +18,9 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableObjectsDTO,
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
@@ -571,13 +571,13 @@ export const invalidateGetObjects = async (
*/
export const patchObjects = (
{ id, relation }: PatchObjectsPathParameters,
coretypesPatchableObjectsDTO: BodyType<CoretypesPatchableObjectsDTO>,
authtypesPatchableObjectsDTO: BodyType<AuthtypesPatchableObjectsDTO>,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: coretypesPatchableObjectsDTO,
data: authtypesPatchableObjectsDTO,
});
};
@@ -590,7 +590,7 @@ export const getPatchObjectsMutationOptions = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
>;
@@ -599,7 +599,7 @@ export const getPatchObjectsMutationOptions = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
> => {
@@ -616,7 +616,7 @@ export const getPatchObjectsMutationOptions = <
Awaited<ReturnType<typeof patchObjects>>,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -630,7 +630,7 @@ export const getPatchObjectsMutationOptions = <
export type PatchObjectsMutationResult = NonNullable<
Awaited<ReturnType<typeof patchObjects>>
>;
export type PatchObjectsMutationBody = BodyType<CoretypesPatchableObjectsDTO>;
export type PatchObjectsMutationBody = BodyType<AuthtypesPatchableObjectsDTO>;
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -645,7 +645,7 @@ export const usePatchObjects = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
>;
@@ -654,7 +654,7 @@ export const usePatchObjects = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
> => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
} else if (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
if (
spans.length > 0 &&
spans[0].timestamp > 0 &&
startTimestampMillis < spans[0].timestamp / 10
) {
const durationMillis = endTimestampMillis - startTimestampMillis;
startTimestampMillis = spans[0].timestamp;
endTimestampMillis = startTimestampMillis + durationMillis;
}
return {
statusCode: 200,
error: null,
message: 'Success',
payload: {
...rawPayload,
spans,
startTimestampMillis,
endTimestampMillis,
},
};
};
export default getTraceV3;

View File

@@ -1,37 +0,0 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -1,57 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
prefix={<X size={14} />}
></Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -1,7 +0,0 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,35 +0,0 @@
import { DrawerWrapper } from '@signozhq/ui';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
);
}
export default DetailsPanelDrawer;

View File

@@ -1,8 +0,0 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -1,10 +0,0 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -1,29 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -55,7 +55,7 @@ describe('GuardAuthZ', () => {
);
render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -79,7 +79,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="read"
object="role:*"
object="dashboard:*"
fallbackOnLoading={<LoadingFallback />}
>
<TestChild />
@@ -102,7 +102,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -121,7 +121,11 @@ describe('GuardAuthZ', () => {
);
render(
<GuardAuthZ relation="read" object="role:*" fallbackOnError={ErrorFallback}>
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={ErrorFallback}
>
<TestChild />
</GuardAuthZ>,
);
@@ -151,7 +155,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="read"
object="role:*"
object="dashboard:*"
fallbackOnError={errorFallbackWithCapture}
>
<TestChild />
@@ -174,7 +178,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -197,7 +201,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="update"
object="role:123"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallback}
>
<TestChild />
@@ -220,7 +224,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="update" object="role:123">
<GuardAuthZ relation="update" object="dashboard:123">
<TestChild />
</GuardAuthZ>,
);
@@ -240,7 +244,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -253,7 +257,7 @@ describe('GuardAuthZ', () => {
});
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
const permission = buildPermission('update', 'role:123');
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -265,7 +269,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="update"
object="role:123"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
>
<TestChild />
@@ -295,7 +299,7 @@ describe('GuardAuthZ', () => {
);
const { rerender } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -305,7 +309,7 @@ describe('GuardAuthZ', () => {
});
rerender(
<GuardAuthZ relation="delete" object="role:456">
<GuardAuthZ relation="delete" object="dashboard:456">
<TestChild />
</GuardAuthZ>,
);

View File

@@ -17,6 +17,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -46,7 +47,6 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -562,9 +562,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView

View File

@@ -1,126 +0,0 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
initialExpression?: string;
/**
* @default false
*/
persistOnUnmount?: boolean;
children: ReactNode;
}
/**
* Provider component that creates a scoped zustand store and exposes
* expression state to children via context.
*/
export function QuerySearchV2Provider({
initialExpression = '',
persistOnUnmount = false,
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef(createExpressionStore());
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
queryParamKey,
parseAsString,
);
const committedExpression = useStore(store, (s) => s.committedExpression);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, initializeFromUrl]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
return (
<QuerySearchV2Context.Provider value={contextValue}>
{children}
</QuerySearchV2Context.Provider>
);
}

View File

@@ -1,60 +0,0 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* User-typed expression (local state, updates on typing)
*/
inputExpression: string;
/**
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
inputExpression: '',
committedExpression: '',
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},
commitExpression: (expression: string): void => {
set({
inputExpression: expression,
committedExpression: expression,
});
},
resetExpression: (): void => {
set({
inputExpression: '',
committedExpression: '',
});
},
initializeFromUrl: (urlExpression: string): void => {
set({
inputExpression: urlExpression,
committedExpression: urlExpression,
});
},
}));
}

View File

@@ -1,95 +0,0 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQuerySearchV2Context } from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
} from '../QuerySearchV2.provider';
const mockSetQueryState = jest.fn();
let mockUrlValue: string | null = null;
jest.mock('nuqs', () => ({
parseAsString: {},
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
}));
function createWrapper(
props: Partial<QuerySearchV2ProviderProps> = {},
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
{children}
</QuerySearchV2Provider>
);
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('');
expect(result.current.userExpression).toBe('');
expect(result.current.initialExpression).toBe('');
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.querySearchProps.onChange('service = "api"');
});
act(() => {
result.current.querySearchProps.onRun('service = "api"');
});
expect(result.current.expression).toBe(
'k8s.pod.name = "my-pod" AND (service = "api")',
);
expect(result.current.userExpression).toBe('service = "api"');
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.userExpression).toBe('status = 500');
expect(result.current.expression).toBe('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useQuerySearchV2Context());
}).toThrow(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -1,61 +0,0 @@
import { createExpressionStore } from '../QuerySearchV2.store';
describe('createExpressionStore', () => {
it('should create a store with initial state', () => {
const store = createExpressionStore();
const state = store.getState();
expect(state.inputExpression).toBe('');
expect(state.committedExpression).toBe('');
});
it('should update inputExpression via setInputExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('');
});
it('should update both expressions via commitExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('service.name = "api"');
});
it('should reset all state via resetExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
store.getState().resetExpression();
expect(store.getState().inputExpression).toBe('');
expect(store.getState().committedExpression).toBe('');
});
it('should initialize from URL value', () => {
const store = createExpressionStore();
store.getState().initializeFromUrl('status = 500');
expect(store.getState().inputExpression).toBe('status = 500');
expect(store.getState().committedExpression).toBe('status = 500');
});
it('should create isolated store instances', () => {
const store1 = createExpressionStore();
const store2 = createExpressionStore();
store1.getState().setInputExpression('expr1');
store2.getState().setInputExpression('expr2');
expect(store1.getState().inputExpression).toBe('expr1');
expect(store2.getState().inputExpression).toBe('expr2');
});
});

View File

@@ -1,17 +0,0 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useContext } from 'react';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
export const QuerySearchV2Context =
createContext<QuerySearchV2ContextValue | null>(null);
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
throw new Error(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
}
return context;
}

View File

@@ -1,8 +0,0 @@
export { useQuerySearchV2Context } from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';

View File

@@ -19,13 +19,6 @@
display: flex;
flex-direction: row;
.query-search-initial-scope-label {
position: absolute;
left: 8px;
top: 10px;
z-index: 10;
}
.query-where-clause-editor {
flex: 1;
min-width: 400px;
@@ -60,10 +53,6 @@
}
}
}
&.hasInitialExpression .cm-editor .cm-content {
padding-left: 22px !important;
}
}
.cm-editor {
@@ -79,6 +68,7 @@
border-radius: 2px;
border: 1px solid var(--l1-border);
padding: 0px !important;
background-color: var(--l1-background) !important;
&:focus-within {
border-color: var(--l1-border);

View File

@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Filter, Info, TriangleAlert } from 'lucide-react';
import { Info, TriangleAlert } from 'lucide-react';
import {
IDetailedError,
IQueryContext,
@@ -47,7 +47,6 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import './QuerySearch.styles.scss';
@@ -86,8 +85,6 @@ interface QuerySearchProps {
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
}
function QuerySearch({
@@ -99,7 +96,6 @@ function QuerySearch({
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -116,26 +112,18 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
const toValidate = isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
: editorDoc;
try {
const validationResponse = validateQuery(toValidate);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
},
[initialExpression, isScopedFilter],
);
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
}, []);
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
@@ -177,8 +165,6 @@ function QuerySearch({
setIsEditorReady(true);
}, []);
const prevQueryDataExpressionRef = useRef<string | undefined>();
useEffect(
() => {
if (!isEditorReady) {
@@ -187,22 +173,13 @@ function QuerySearch({
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
const prevExpression = prevQueryDataExpressionRef.current;
// Only sync editor when queryData.filter?.expression actually changed from external source
// Not when focus changed (which would reset uncommitted user input)
const queryDataExpressionChanged = prevExpression !== newExpression;
prevQueryDataExpressionRef.current = newExpression;
if (
queryDataExpressionChanged &&
newExpression !== currentExpression &&
!isFocused
) {
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
updateEditorValue(newExpression, { skipOnChange: true });
}
if (!isFocused) {
validateExpressionForEditor(currentExpression);
if (newExpression) {
handleQueryValidation(newExpression);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -307,7 +284,7 @@ function QuerySearch({
}
});
}
setKeySuggestions([...merged.values()]);
setKeySuggestions(Array.from(merged.values()));
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
@@ -360,7 +337,7 @@ function QuerySearch({
// If value contains single quotes, escape them and wrap in single quotes
if (value.includes("'")) {
// Replace single quotes with escaped single quotes
const escapedValue = value.replaceAll(/'/g, "\\'");
const escapedValue = value.replace(/'/g, "\\'");
return `'${escapedValue}'`;
}
@@ -637,7 +614,7 @@ function QuerySearch({
const handleBlur = (): void => {
const currentExpression = getCurrentExpression();
validateExpressionForEditor(currentExpression);
handleQueryValidation(currentExpression);
setIsFocused(false);
};
@@ -655,6 +632,7 @@ function QuerySearch({
);
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
@@ -919,12 +897,12 @@ function QuerySearch({
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.has(option.label) ? -10 : 10,
boost: usedKeys.includes(option.label) ? -10 : 10,
}));
}
@@ -1339,19 +1317,6 @@ function QuerySearch({
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
@@ -1391,7 +1356,6 @@ function QuerySearch({
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
@@ -1426,12 +1390,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
onRun(getCurrentExpression());
}
return true;
},
@@ -1596,7 +1555,6 @@ QuerySearch.defaultProps = {
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
};
export default QuerySearch;

View File

@@ -1,58 +0,0 @@
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});

View File

@@ -1,40 +0,0 @@
export function combineInitialAndUserExpression(
initial: string,
user: string,
): string {
const i = initial.trim();
const u = user.trim();
if (!i) {
return u;
}
if (!u) {
return i;
}
return `${i} AND (${u})`;
}
export function getUserExpressionFromCombined(
initial: string,
combined: string | null | undefined,
): string {
const i = initial.trim();
const c = (combined ?? '').trim();
if (!c) {
return '';
}
if (!i) {
return c;
}
if (c === i) {
return '';
}
const wrappedPrefix = `${i} AND (`;
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
return c.slice(wrappedPrefix.length, -1);
}
const plainPrefix = `${i} AND `;
if (c.startsWith(plainPrefix)) {
return c.slice(plainPrefix.length);
}
return c;
}

View File

@@ -1,14 +0,0 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useQuerySearchV2Context,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {
QueryBuilderV2Provider,
useQueryBuilderV2Context,
} from './QueryBuilderV2Context';

View File

@@ -47,16 +47,10 @@ function TanStackCustomTableRow<TData>({
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const enableAlternatingRowColors =
context?.enableAlternatingRowColors ?? false;
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
enableAlternatingRowColors &&
(item.row.index % 2 === 0
? tableStyles.tableRowEven
: tableStyles.tableRowOdd),
extraClass,
);
@@ -111,12 +105,6 @@ function areTableRowPropsEqual<TData>(
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
return false;
}
if (
prev.context?.enableAlternatingRowColors !==
next.context?.enableAlternatingRowColors
) {
return false;
}
if (prev.context !== next.context) {
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;

View File

@@ -2,10 +2,7 @@
position: sticky;
top: 0;
z-index: 2;
padding: var(--tanstack-cell-padding-top, 0.3rem)
var(--tanstack-cell-padding-right, 0.3rem)
var(--tanstack-cell-padding-bottom, 0.3rem)
var(--tanstack-cell-padding-left, 0.3rem);
padding: 0.3rem;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -22,17 +19,7 @@
}
border: none !important;
background-color: var(
--tanstack-table-header-cell-bg,
var(--l2-background)
) !important;
&:first-child {
background-color: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-header-cell-bg, var(--l2-background))
) !important;
}
background-color: var(--l2-background) !important;
}
.tanstackHeaderContent {
@@ -74,7 +61,7 @@
width: 12px;
height: 12px;
cursor: grab;
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
opacity: 1;
touch-action: none;
}
@@ -87,7 +74,7 @@
height: 20px;
cursor: pointer;
flex-shrink: 0;
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
margin-left: auto;
}
@@ -95,9 +82,8 @@
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
border: 1px solid
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
box-shadow: none;
}
@@ -151,7 +137,7 @@
}
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
background: var(--bg-robin-300);
}
.tanstackResizeHandleLine {
@@ -161,7 +147,7 @@
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
background: var(--l2-background);
opacity: 1;
pointer-events: none;
transition:
@@ -169,34 +155,13 @@
width 120ms ease;
}
.tanstackHeaderCell:first-child .tanstackResizeHandleLine {
background: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
);
}
.cursorColResize:hover .tanstackResizeHandleLine {
background: var(--tanstack-table-resize-handle-hover-bg, var(--l2-border));
}
.tanstackHeaderCell:first-child
.cursorColResize:hover
.tanstackResizeHandleLine {
background: color-mix(
in srgb,
var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
)
60%,
black
);
background: var(--l2-border);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
background: var(--bg-robin-500);
transition: none;
}
@@ -248,12 +213,7 @@
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l3-foreground);
&[data-sort-direction='asc'],
&[data-sort-direction='desc'] {
color: var(--primary);
}
color: var(--l2-foreground);
}
.isSortable {

View File

@@ -9,7 +9,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import cx from 'classnames';
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
@@ -177,17 +177,12 @@ function TanStackHeaderRow<TData>({
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span
className={headerStyles.tanstackSortIndicator}
data-sort-direction={currentSortDirection || 'none'}
>
<span className={headerStyles.tanstackSortIndicator}>
{currentSortDirection === 'asc' ? (
<ArrowUp size={SORT_ICON_SIZE} />
<ChevronUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ArrowDown size={SORT_ICON_SIZE} />
) : (
<ArrowUpDown size={SORT_ICON_SIZE} />
)}
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
</span>
</button>
) : (

View File

@@ -1,22 +1,4 @@
.tanStackTable {
--tanstack-cell-padding: var(--tanstack-cell-padding-override, 0.3rem);
--tanstack-cell-padding-left: var(
--tanstack-cell-padding-left-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-right: var(
--tanstack-cell-padding-right-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-top: var(
--tanstack-cell-padding-top-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-bottom: var(
--tanstack-cell-padding-bottom-override,
var(--tanstack-cell-padding)
);
width: 100%;
border-collapse: separate;
border-spacing: 0;
@@ -44,7 +26,7 @@
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
max-width: 100%;
word-break: break-all;
}
@@ -60,35 +42,13 @@
}
.tableCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: var(--tanstack-table-row-height, auto);
padding: 0.3rem;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
background-color: var(--tanstack-table-cell-bg, transparent);
&:first-child {
padding: var(
--tanstack-cell-padding-top-first-column,
var(--tanstack-cell-padding-top)
)
var(
--tanstack-cell-padding-right-first-column,
var(--tanstack-cell-padding-right)
)
var(
--tanstack-cell-padding-bottom-first-column,
var(--tanstack-cell-padding-bottom)
)
var(
--tanstack-cell-padding-left-first-column,
var(--tanstack-cell-padding-left)
);
}
color: var(--l2-foreground);
}
.tableRow {
@@ -98,69 +58,19 @@
&:hover {
.tableCell {
background-color: var(
--tanstack-table-row-hover-bg,
var(--row-hover-bg)
) !important;
background-color: var(--row-hover-bg) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(
--tanstack-table-row-active-bg,
var(--row-active-bg)
) !important;
background-color: var(--row-active-bg) !important;
}
}
&.tableRowOdd {
.tableCell {
background-color: var(--tanstack-table-row-odd-bg, transparent);
}
}
&.tableRowEven {
.tableCell {
background-color: var(--tanstack-table-row-even-bg, transparent);
}
}
.tableCell:first-child {
background-color: var(
--tanstack-first-column-bg,
var(--tanstack-table-cell-bg, transparent)
);
color: var(
--tanstack-first-column-color,
var(--tanstack-table-cell-color, var(--l2-foreground))
);
}
&.tableRowOdd .tableCell:first-child {
background-color: var(
--tanstack-first-column-odd-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-odd-bg, transparent)
)
);
}
&.tableRowEven .tableCell:first-child {
background-color: var(
--tanstack-first-column-even-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-even-bg, transparent)
)
);
}
}
.tableHeaderCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
padding: 0.3rem;
height: 36px;
text-align: left;
font-size: 14px;
@@ -168,40 +78,20 @@
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
background: var(--tanstack-table-header-cell-bg, var(--l1-background));
border-bottom: 1px solid var(--tanstack-table-header-border-color, transparent);
color: var(--l1-foreground);
// TODO: Remove this once background color (l1) is matching the actual background color of the page
&[data-dark-mode='true'] {
background: #0b0c0d;
}
&[data-dark-mode='false'] {
background: #fdfdfd;
}
}
.tableRowExpansion {
display: table-row;
.tableCellExpansion {
background-color: var(--tanstack-table-header-cell-bg, var(--l1-background));
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
}
& > td {
padding: 0;
}
& thead th:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-3) - 1px)
);
}
& tbody td:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-20) + var(--spacing-4))
);
}
:global(thead) {
position: unset !important;
}
}
.tableCellExpansion {

View File

@@ -90,7 +90,6 @@ function TanStackTableInner<TData>(
skeletonRowCount = 10,
enableQueryParams,
pagination,
paginationClassname,
onEndReached,
getRowKey,
getItemKey,
@@ -113,17 +112,9 @@ function TanStackTableInner<TData>(
testId,
prefixPaginationContent,
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
if (disableVirtualScroll && onEndReached) {
throw new Error(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
}
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
@@ -230,15 +221,6 @@ function TanStackTableInner<TData>(
[getRowCanExpand],
);
const isExpandEnabled = Boolean(renderExpandedRow);
useEffect(() => {
const hasExpanded =
typeof expanded === 'boolean' ? expanded : Object.keys(expanded).length > 0;
if (!isExpandEnabled && hasExpanded) {
setExpanded({});
}
}, [isExpandEnabled, expanded, setExpanded]);
const table = useReactTable({
data: effectiveData,
columns: tanstackColumns,
@@ -247,7 +229,7 @@ function TanStackTableInner<TData>(
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: isExpandEnabled,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
@@ -351,7 +333,6 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
}),
[
getRowStyle,
@@ -369,7 +350,6 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
],
);
@@ -520,10 +500,7 @@ function TanStackTableInner<TData>(
);
return (
<div
className={cx(viewStyles.tanstackTableViewWrapper, className)}
data-has-group-by={(groupBy?.length || 0) > 0}
>
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
@@ -531,53 +508,23 @@ function TanStackTableInner<TData>(
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
{disableVirtualScroll ? (
<div
className={virtuosoClassName}
{...restTableScrollerProps}
data-testid={testId}
>
<table className={tableStyles.tanStackTable} style={virtuosoTableStyle}>
<VirtuosoTableColGroup columns={effectiveColumns} table={table} />
<thead>{tableHeader()}</thead>
<tbody>
{(isLoading && data.length === 0
? flatItems.slice(0, skeletonRowCount)
: flatItems
).map((item, index) => (
<TanStackCustomTableRow
key={
item.kind === 'expansion' ? `${item.row.id}-expansion` : item.row.id
}
item={item}
context={virtuosoContext}
data-index={index}
data-item-index={index}
data-known-size={0}
/>
))}
</tbody>
</table>
</div>
) : (
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
)}
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
{showInfiniteScrollLoader && (
<div
className={viewStyles.tanstackLoadingOverlay}
@@ -587,18 +534,8 @@ function TanStackTableInner<TData>(
</div>
)}
{showPagination && pagination && (
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
<div className={viewStyles.paginationContainer}>
{prefixPaginationContent}
{pagination.showTotalCount && effectiveTotalCount > 0 && (
<span
className={viewStyles.paginationTotalCount}
data-testid="pagination-total-count"
>
Showing {(page - 1) * limit + 1} -{' '}
{Math.min(page * limit, effectiveTotalCount)} of {effectiveTotalCount}
{pagination.totalCountLabel ? ` ${pagination.totalCountLabel}` : ''}
</span>
)}
<Pagination
current={page}
pageSize={limit}

View File

@@ -49,8 +49,7 @@
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-slate-300))
transparent;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
@@ -66,12 +65,12 @@
}
&::-webkit-scrollbar-thumb {
background: var(--tanstack-table-scrollbar-color, var(--bg-slate-300));
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--tanstack-table-scrollbar-hover-color, var(--bg-slate-200));
background: var(--bg-slate-200);
}
&.cellTypographySmall {
@@ -117,10 +116,6 @@
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
ul {
padding: 0;
}
}
.paginationPageSize {
@@ -128,11 +123,6 @@
--combobox-trigger-height: 2rem;
}
.paginationTotalCount {
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.tanstackLoadingOverlay {
position: absolute;
left: 50%;
@@ -145,25 +135,18 @@
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
background: var(--tanstack-table-loading-overlay-bg, var(--l1-background));
box-shadow: var(
--tanstack-table-loading-overlay-shadow,
0 2px 8px rgba(0, 0, 0, 0.15)
);
background: var(--l1-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300))
transparent;
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300));
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(
--tanstack-table-scrollbar-hover-color,
var(--bg-vanilla-100)
);
background: var(--bg-vanilla-100);
}
}

View File

@@ -188,87 +188,6 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
});
});
it('renders total count when showTotalCount is true', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
},
},
});
await waitFor(() => {
const totalCount = screen.getByTestId('pagination-total-count');
expect(totalCount).toBeInTheDocument();
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 100');
});
});
it('renders total count with label when totalCountLabel is provided', async () => {
renderTanStackTable({
props: {
pagination: {
total: 50,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
totalCountLabel: 'Pods',
},
},
});
await waitFor(() => {
const totalCount = screen.getByTestId('pagination-total-count');
expect(totalCount).toBeInTheDocument();
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 50 Pods');
});
});
it('does not render total count when showTotalCount is false', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: false,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
expect(
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
it('does not render total count when total is 0', async () => {
renderTanStackTable({
props: {
pagination: {
total: 0,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
},
},
});
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
expect(
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
});
describe('sorting', () => {
@@ -470,101 +389,6 @@ describe('TanStackTableView Integration', () => {
// by checking the table renders without errors
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders without errors when expanded state exists but expansion is disabled', async () => {
// This tests that the table handles the case where URL has expanded state
// but renderExpandedRow is undefined (expansion disabled).
// The table's useEffect should reset expanded state automatically.
renderTanStackTable({
props: {
enableQueryParams: true,
// renderExpandedRow is undefined - expansion disabled
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Table should render without any expanded rows
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
});
it('renders expanded rows with unique keys in non-virtualized mode', async () => {
// This tests that row and expansion items have unique keys to avoid
// React's "duplicate key" warning when disableVirtualScroll is true
renderTanStackTable({
props: {
disableVirtualScroll: true,
enableQueryParams: true,
renderExpandedRow: (row) => (
<div data-testid={`expanded-${row.id}`}>Expanded: {row.name}</div>
),
getRowCanExpand: () => true,
getRowKey: (row) => row.id,
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Both the row and its expansion content should be rendered
expect(screen.getByTestId('expanded-1')).toBeInTheDocument();
expect(screen.getByText('Expanded: Item 1')).toBeInTheDocument();
// Verify all 3 data rows plus 1 expansion row = 4 tr elements in tbody
const tbody = screen.getByRole('table').querySelector('tbody');
expect(tbody?.querySelectorAll('tr')).toHaveLength(4);
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
onEndReached: jest.fn(),
},
});
}).toThrow(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
});
it('renders all rows without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
// Verify table structure exists
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders column headers without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
});
});
describe('infinite scroll', () => {

View File

@@ -138,10 +138,7 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
wrapper,
});
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(3);
});
@@ -252,294 +249,3 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
expect(result.current.orderBy).toBeNull();
});
});
describe('useTableParams (selective URL mode — partial config object)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('syncs only page to URL when only page is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ page: 'myPage' }), {
wrapper,
});
// Update page - should sync to URL
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
expect(result.current.page).toBe(5);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myPage'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('5');
// Update limit - should stay local (not in URL)
act(() => {
result.current.setLimit(100);
jest.runAllTimers();
});
expect(result.current.limit).toBe(100);
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('limit') !== null,
);
expect(limitInUrl).toBe(false);
// Update orderBy - should stay local (not in URL)
act(() => {
result.current.setOrderBy({ columnName: 'test', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'test',
order: 'asc',
});
const orderByInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('order_by') !== null,
);
expect(orderByInUrl).toBe(false);
});
it('syncs only orderBy to URL when only orderBy is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ orderBy: 'mySort' }), {
wrapper,
});
// Update orderBy - should sync to URL
act(() => {
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('mySort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
expect(JSON.parse(lastOrderBy!)).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
// Update page - should stay local
act(() => {
result.current.setPage(3);
jest.runAllTimers();
});
expect(result.current.page).toBe(3);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only limit to URL when only limit is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ limit: 'myLimit' }), {
wrapper,
});
// Update limit - should sync to URL
act(() => {
result.current.setLimit(25);
jest.runAllTimers();
});
expect(result.current.limit).toBe(25);
const lastLimit = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myLimit'))
.filter(Boolean)
.pop();
expect(lastLimit).toBe('25');
// Update page - should stay local
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only expanded to URL when only expanded is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ expanded: 'myExpanded' }),
{ wrapper },
);
// Update expanded - should sync to URL
act(() => {
result.current.setExpanded({ 'row-1': true, 'row-2': true });
jest.runAllTimers();
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
const lastExpanded = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myExpanded'))
.filter(Boolean)
.pop();
expect(lastExpanded).toBeDefined();
expect(JSON.parse(lastExpanded!)).toEqual(
expect.arrayContaining(['row-1', 'row-2']),
);
// Update page - should stay local
act(() => {
result.current.setPage(4);
jest.runAllTimers();
});
expect(result.current.page).toBe(4);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs page and orderBy to URL but keeps limit and expanded local', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ page: 'p', orderBy: 'sort' }),
{ wrapper },
);
// Update limit and expanded first (should stay local)
act(() => {
result.current.setLimit(75);
result.current.setExpanded({ 'row-5': true });
jest.runAllTimers();
});
expect(result.current.limit).toBe(75);
expect(result.current.expanded).toStrictEqual({ 'row-5': true });
// Update page (should sync to URL)
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('p'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
// Update orderBy (should sync to URL, and resets page to default)
act(() => {
result.current.setOrderBy({ columnName: 'name', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'name',
order: 'asc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('sort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
// limit should NOT be in URL
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('limit') !== null ||
call[0].searchParams.get('myLimit') !== null,
);
expect(limitInUrl).toBe(false);
// expanded should NOT be in URL
const expandedInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('expanded') !== null ||
call[0].searchParams.get('myExpanded') !== null,
);
expect(expandedInUrl).toBe(false);
});
it('reads initial values from URL for configured params only', () => {
const wrapper = createNuqsWrapper({
customPage: '7',
limit: '999', // This should be ignored since limit is not configured
});
const { result } = renderHook(
// Pass page default matching URL to prevent reset on mount
() => useTableParams({ page: 'customPage' }, { page: 7 }),
{ wrapper },
);
// Page should come from URL
expect(result.current.page).toBe(7);
// Limit should be default (not from URL since it's not configured)
expect(result.current.limit).toBe(50);
});
it('supports updater function for expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams({ expanded: 'exp' }), {
wrapper,
});
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-1': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-1': true });
// Use updater function to add another row
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-2': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
});
it('supports updater function for local expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-a': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-a': true });
// Use updater function
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-b': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-a': true,
'row-b': true,
});
});
});

View File

@@ -171,27 +171,6 @@ export * from './useTableParams';
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
* />
* ```
*
* @example Disable virtual scroll — useful for nested tables inside expanded rows.
* Virtual scroll requires a fixed height container, which is problematic for nested tables
* that need dynamic height. Use `disableVirtualScroll` when rendering tables inside
* `renderExpandedRow` to allow the nested table to grow based on content.
* Note: Cannot be combined with `onEndReached` (infinite scroll requires virtualization).
* ```tsx
* // Parent table with expandable rows
* <TanStackTable
* data={parentData}
* columns={parentColumns}
* renderExpandedRow={(row) => (
* // Nested table without virtualization — height adapts to content
* <TanStackTable
* data={row.children}
* columns={childColumns}
* disableVirtualScroll
* />
* )}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -107,23 +107,19 @@ export type TableRowContext<TData> = {
columnOrderKey: string;
/** Column visibility key for memo invalidation on visibility change */
columnVisibilityKey: string;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
showTotalCount?: boolean;
totalCountLabel?: string;
};
export type TanstackTableQueryParamsConfig = {
page?: string;
limit?: string;
orderBy?: string;
expanded?: string;
page: string;
limit: string;
orderBy: string;
expanded: string;
};
export type TanStackTableProps<TData> = {
@@ -141,7 +137,6 @@ export type TanStackTableProps<TData> = {
skeletonRowCount?: number;
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
paginationClassname?: string;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */
@@ -181,10 +176,6 @@ export type TanStackTableProps<TData> = {
prefixPaginationContent?: ReactNode;
/** Content rendered after the pagination controls */
suffixPaginationContent?: ReactNode;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
disableVirtualScroll?: boolean;
};
export type TanStackTableHandle = TableVirtuosoHandle & {

View File

@@ -8,12 +8,6 @@ import { SortState, TanstackTableQueryParamsConfig } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
const URL_KEYS_DEFAULT = {
page: 'page',
limit: 'limit',
orderBy: 'order_by',
expanded: 'expanded',
} as const;
type Defaults = {
page?: number;
@@ -55,49 +49,30 @@ export function useTableParams(
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
defaults?: Defaults,
): TableParamsResult {
// Determine which params should sync to URL vs use local state
const isObjectConfig = typeof enableQueryParams === 'object';
const useUrlForPage =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.page !== undefined);
const useUrlForLimit =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.limit !== undefined);
const useUrlForOrderBy =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.orderBy !== undefined);
const useUrlForExpanded =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.expanded !== undefined);
const pageQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
: isObjectConfig
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
: URL_KEYS_DEFAULT.page;
? `${enableQueryParams}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
: isObjectConfig
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
: URL_KEYS_DEFAULT.limit;
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
: isObjectConfig
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
: URL_KEYS_DEFAULT.orderBy;
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
: isObjectConfig
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
? `${enableQueryParams}_expanded`
: typeof enableQueryParams === 'object'
? enableQueryParams.expanded
: 'expanded';
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
@@ -174,29 +149,45 @@ export function useTableParams(
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const isEnabledQueryParams =
typeof enableQueryParams === 'string' ||
typeof enableQueryParams === 'object';
useEffect(() => {
if (useUrlForPage) {
if (isEnabledQueryParams) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
useUrlForPage,
isEnabledQueryParams,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
setUrlPage,
]);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
expanded: urlExpanded,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
setExpanded: setUrlExpanded,
};
}
return {
page: useUrlForPage ? urlPage : localPage,
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
};
}

View File

@@ -35,10 +35,7 @@ export const getColumnWidthStyle = <TData>(
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
};
return { width: '100%' };
}
const { width } = column;
@@ -62,19 +59,10 @@ export const getColumnWidthStyle = <TData>(
};
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');
};
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
// Skip accessor for skeleton rows to avoid errors with missing properties
if (isSkeletonRow(row)) {
return undefined;
}
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}

View File

@@ -1,20 +0,0 @@
.timeline-v3-container {
overflow: visible;
position: relative;
}
.timeline-v3-cursor-badge {
position: absolute;
top: 0;
transform: translateX(-50%);
background: var(--l3-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--l1-foreground);
white-space: nowrap;
pointer-events: none;
z-index: 1;
}

View File

@@ -1,117 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
/** Cursor X as a fraction of the timeline width (01). null = no cursor. */
cursorXPercent?: number | null;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
cursorXPercent,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const spread = endTimestamp - startTimestamp;
useEffect(() => {
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const newIntervals = getIntervals(
intervalisedSpread,
spread,
offsetTimestamp,
);
setIntervals(newIntervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
// Compute cursor time label using the same unit as timeline ticks
const cursorLabel = useMemo(() => {
if (cursorXPercent == null || spread <= 0) {
return null;
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
const unit = getIntervalUnit(spread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <></>;
}
const strokeColor = 'var(--l3-foreground)';
const svgHeight = timelineHeight * 2.5;
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={svgHeight}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
{/* Cursor time badge — DOM element for easy CSS styling */}
{cursorX !== null && cursorLabel && (
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
{cursorLabel}
</div>
)}
</div>
);
}
export default TimelineV3;

View File

@@ -1,109 +0,0 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as timeline ticks.
*/
export function getIntervalUnit(
spread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
unit = INTERVAL_UNITS[idx];
break;
}
}
return unit;
}
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
// Only show even-interval ticks — skip the trailing partial tick at the edge.
// The last even tick sits before the full width, so it doesn't conflict with
// span duration labels that may have sub-millisecond precision.
let elapsedIntervals = 0;
while (
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
intervals.length < 20
) {
elapsedIntervals += intervalSpreadNormalized;
const labelTime = offsetTimestamp + elapsedIntervals;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (elapsedIntervals / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -41,7 +41,11 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
@@ -75,7 +79,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {
@@ -109,7 +113,7 @@ describe('createGuardedRoute', () => {
relation: txn.relation,
object: {
resource: {
kind: txn.object.resource.kind,
name: txn.object.resource.name,
type: txn.object.resource.type,
},
selector: '123:456',
@@ -127,7 +131,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'role:{id}:{version}',
'dashboard:{id}:{version}',
);
const mockMatch = {
@@ -162,7 +166,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {
@@ -197,7 +201,11 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
@@ -228,7 +236,11 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
@@ -266,7 +278,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {
@@ -292,7 +304,7 @@ describe('createGuardedRoute', () => {
});
expect(screen.getByText('update')).toBeInTheDocument();
expect(screen.getByText('role:123')).toBeInTheDocument();
expect(screen.getByText('dashboard:123')).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
@@ -323,7 +335,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
ComponentWithMultipleProps,
'read',
'role:*',
'dashboard:*',
);
const mockMatch = {
@@ -358,10 +370,10 @@ describe('createGuardedRoute', () => {
requestCount++;
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const obj = payload[0]?.object;
const kind = obj?.resource?.kind;
const name = obj?.resource?.name;
const selector = obj?.selector ?? '*';
const objectStr =
obj?.resource?.type === 'metaresources' ? kind : `${kind}:${selector}`;
obj?.resource?.type === 'metaresources' ? name : `${name}:${selector}`;
requestedObjects.push(objectStr ?? '');
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
@@ -371,7 +383,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
'dashboard:{id}',
);
const mockMatch1 = {
@@ -395,7 +407,7 @@ describe('createGuardedRoute', () => {
});
expect(requestCount).toBe(1);
expect(requestedObjects).toContain('role:123');
expect(requestedObjects).toContain('dashboard:123');
unmount();
@@ -420,7 +432,7 @@ describe('createGuardedRoute', () => {
});
expect(requestCount).toBe(2);
expect(requestedObjects).toContain('role:456');
expect(requestedObjects).toContain('dashboard:456');
});
it('should handle different relation types', async () => {
@@ -434,7 +446,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'delete',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {

View File

@@ -6,7 +6,6 @@ export enum Events {
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
}
export enum InfraMonitoringEvents {

View File

@@ -37,7 +37,5 @@ export enum LOCALSTORAGE {
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -33,7 +33,6 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -8,7 +8,6 @@ const ROUTES = {
SERVICE_MAP: '/service-map',
TRACE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',

View File

@@ -33,102 +33,6 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
blue1: '#2F80ED',
blue2: '#3366E6',
blue3: '#4682B4',
blue4: '#1F63E0',
blue5: '#3A7AED',
blue6: '#5A9DF5',
blue7: '#2874A6',
blue8: '#2E86C1',
blue9: '#3498DB',
blue10: '#1E90FF',
blue11: '#4169E1',
// Cyans / Teals
cyan1: '#00CEC9',
cyan2: '#22A6F2',
cyan3: '#00B0AA',
cyan4: '#33D6C2',
cyan5: '#66E9DA',
cyan6: '#48DBFB',
cyan7: '#00BFFF',
cyan8: '#63B8FF',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#76D7C4',
teal5: '#20B2AA',
// Greens
green1: '#27AE60',
green2: '#3CB371',
green3: '#1E8449',
green4: '#2ECC71',
green5: '#58D68D',
green6: '#229954',
green7: '#52BE80',
green8: '#82E0AA',
green9: '#73C6B6',
// Limes
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#84CC16',
lime4: '#65A30D',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
yellow4: '#F4D03F',
yellow5: '#D4AC0D',
// Golds / Ambers
gold1: '#F2C94C',
gold2: '#FFD93D',
gold3: '#FFCA28',
gold4: '#B7950B',
gold5: '#D4A017',
// Oranges (non-red)
orange1: '#F39C12',
orange2: '#E67E22',
orange3: '#F5B041',
orange4: '#D35400',
orange5: '#EB984E',
orange6: '#FAD7A0',
// Purples / Violets
purple1: '#BB6BD9',
purple2: '#9B51E0',
purple3: '#DA77F2',
purple4: '#C77DFF',
purple5: '#6C5CE7',
purple6: '#8E44AD',
purple7: '#9B59B6',
purple8: '#BB8FCE',
purple9: '#7D3C98',
purple10: '#A569BD',
// Lavenders
lavender1: '#AF7AC5',
lavender2: '#C39BD3',
lavender3: '#D2B4DE',
// Pinks / Magentas
pink1: '#E91E8C',
pink2: '#FF6FD8',
pink3: '#F06292',
pink4: '#CE93D8',
// Salmons / Corals (distinct from error red)
salmon1: '#FF8A65',
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel your subscription', { selector: 'span' }),
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel your subscription', { selector: 'span' }),
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,11 +1,11 @@
.banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
@@ -15,55 +15,21 @@
gap: var(--spacing-2);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
line-height: var(--paragraph-base-500-line-height);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
padding-left: 20px;
color: var(--callout-error-icon);
}
.dialogBody {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.dialogDescription {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
margin: 0;
}
.dialogConfirmLabel {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
code {
font-family: var(--font-mono);
color: var(--l1-foreground);
}
}
.cancelButton {
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}

View File

@@ -1,4 +1,4 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -13,16 +13,14 @@ describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel your subscription', { selector: 'span' }),
screen.getByText('Cancel Subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText(
/When you cancel your SigNoz subscription, all your data will be deleted/i,
),
screen.getByText('Cancel your SigNoz subscription.'),
).toBeInTheDocument();
});
it('opens dialog with content when Cancel Subscription is clicked', async () => {
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -32,62 +30,17 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/Cancelling your subscription would stop your data/i),
screen.getByText(/reach out to our support team/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter the word cancel/i),
screen.getByRole('button', { name: /keep subscription/i }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel subscription/i }),
screen.getByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
await user.type(input, 'el');
expect(confirmButton).toBeEnabled();
});
it('closes dialog and resets input when Go back is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
@@ -104,13 +57,7 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');

View File

@@ -1,17 +1,15 @@
import { useState } from 'react';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { Button, DialogWrapper, Input } from '@signozhq/ui';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
@@ -55,12 +53,6 @@ function CancelSubscriptionBanner(): JSX.Element {
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
setConfirmText('');
};
const handleClose = (): void => {
setOpen(false);
setConfirmText('');
};
const footer = (
@@ -68,19 +60,12 @@ function CancelSubscriptionBanner(): JSX.Element {
<Button
variant="solid"
color="secondary"
prefix={<Undo2 size={14} />}
onClick={handleClose}
onClick={(): void => setOpen(false)}
>
Go back
Keep Subscription
</Button>
<Button
variant="solid"
color="destructive"
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
>
Cancel subscription
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
</Button>
</>
);
@@ -89,47 +74,30 @@ function CancelSubscriptionBanner(): JSX.Element {
<>
<div className={styles.banner}>
<div className={styles.info}>
<div className={styles.titleRow}>
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
<span className={styles.title}>Cancel your subscription</span>
</div>
<span className={styles.subtitle}>
When you cancel your SigNoz subscription, all your data will be deleted
immediately and removed from our servers.
</span>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
</div>
<Button
variant="solid"
color="secondary"
color="destructive"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={handleClose}
title="Cancel your subscription?"
onOpenChange={setOpen}
title="Cancel your subscription"
width="narrow"
showCloseButton={false}
footer={footer}
>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
</DialogWrapper>
</>
);

View File

@@ -1,227 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,143 @@
.overview-content {
display: flex;
flex-direction: column;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.name-icon-input {
display: flex;
.dashboard-image-input {
.ant-select-selector {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.dashboard-name-input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.dashboard-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-text-area {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.overview-settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
.unsaved {
display: flex;
align-items: center;
gap: 8px;
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsaved-changes {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
}
.footer-action-btns {
display: flex;
gap: 8px;
.discard-btn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.save-btn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
}
.dashboard-image-input {
&.ant-select-dropdown {
padding: 0px !important;
}
.ant-select-item {
padding: 0px;
align-items: center;
justify-content: center;
.ant-select-item-option-content {
display: flex;
align-items: center;
justify-content: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}

View File

@@ -1,24 +1,16 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Tooltip, Typography } from 'antd';
import { Col, Input, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
import './GeneralSettings.styles.scss';
const { Option } = Select;
@@ -27,13 +19,6 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
@@ -115,8 +100,8 @@ function GeneralDashboardSettings(): JSX.Element {
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<div className="overview-content">
<Col className="overview-settings">
<Space
direction="vertical"
style={{
@@ -127,29 +112,27 @@ function GeneralDashboardSettings(): JSX.Element {
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Dashboard Name
</Typography>
<section className="name-icon-input">
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
rootClassName="dashboard-image-input"
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
<img src={icon} alt="dashboard-icon" className="list-item-image" />
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
@@ -157,119 +140,41 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Description
</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
className="description-text-area"
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Tags
</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
<div className="overview-settings-footer">
<div className="unsaved">
<div className="unsaved-dot" />
<Typography.Text className="unsaved-changes">
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<div className="footer-action-btns">
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
className="discard-btn"
>
Discard
</Button>
@@ -283,7 +188,7 @@ function GeneralDashboardSettings(): JSX.Element {
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
className="save-btn"
>
{t('save')}
</Button>

View File

@@ -33,13 +33,11 @@ export default function BarChart(props: BarChartProps): JSX.Element {
}
const tooltipProps: BarTooltipProps = {
...props,
id: config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -50,7 +48,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);

View File

@@ -29,12 +29,11 @@ export default function ChartWrapper({
onClick,
syncMode,
syncKey,
syncFilterMode,
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
groupByPerQuery,
groupBy,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -70,10 +69,9 @@ export default function ChartWrapper({
const syncMetadata = useMemo(
() => ({
yAxisUnit,
groupByPerQuery,
filterMode: syncFilterMode,
groupBy,
}),
[yAxisUnit, groupByPerQuery, syncFilterMode],
[yAxisUnit, groupBy],
);
return (

View File

@@ -24,21 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
id: rest.config.getId(),
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <HistogramTooltip {...tooltipProps} />;
},
[
customTooltip,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
);
return (

View File

@@ -9,7 +9,7 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, ...rest } = props;
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -18,12 +18,10 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
id: rest.config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
@@ -33,12 +31,15 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (
<ChartWrapper {...rest} customTooltip={renderTooltip}>
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);

View File

@@ -1,14 +1,9 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -26,7 +21,6 @@ interface BaseChartProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
@@ -36,7 +30,6 @@ interface UPlotBasedChartProps {
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncFilterMode?: SyncTooltipFilterMode;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
@@ -46,7 +39,7 @@ interface UPlotBasedChartProps {
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
groupBy?: BaseAutocompleteData[];
}
export interface TimeSeriesChartProps

View File

@@ -1,15 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -20,7 +14,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import get from 'lodash/get';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -30,7 +24,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
@@ -41,10 +34,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -86,11 +75,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -130,20 +114,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
uPlotRef.current = plot;
}, []);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -155,14 +133,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
groupBy={groupBy}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,11 +1,8 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -16,7 +13,6 @@ import {
} from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -79,20 +75,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
widget.mergeAllActiveQueries,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter
id={widget.id}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
@@ -115,7 +97,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
/>
)}
</div>

View File

@@ -1,26 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -30,7 +24,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
@@ -40,10 +33,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -92,11 +81,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -121,20 +105,14 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
widget.decimalPrecision,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -144,13 +122,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
groupByPerQuery={groupByPerQuery}
groupBy={groupBy}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,93 +0,0 @@
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
isPinned: false,
dismiss: jest.fn(),
};
describe('when not pinned', () => {
it('renders the drilldown and pin hints by default', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
});
it('hides the drilldown hint when canDrilldown is false', () => {
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
});
it('renders a custom pin key in uppercase', () => {
render(<TooltipFooter {...defaultProps} pinKey="x" />);
expect(screen.getByText('X')).toBeInTheDocument();
});
it('does not render the unpin button', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
});
describe('when pinned', () => {
it('renders the unpin hint with pin key and Esc', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByText('to unpin')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
expect(screen.getByText('Esc')).toBeInTheDocument();
});
it('renders the unpin button', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /unpin tooltip/i }),
).toBeInTheDocument();
});
it('hides the drilldown and pin-instruction hints', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
});
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const dismiss = jest.fn();
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -123,7 +123,6 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { Button } from '@signozhq/ui';
import { Button, PersistedAnnouncementBanner } from '@signozhq/ui';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
@@ -11,6 +11,7 @@ import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -270,6 +271,23 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{user?.role === USER_ROLES.ADMIN && (
<PersistedAnnouncementBanner
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been
migrated to service accounts.
</>
</PersistedAnnouncementBanner>
)}
<div className="sticky-header">
<Header
leftComponent={

View File

@@ -11,8 +11,8 @@ import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -32,9 +32,9 @@ import {
hostWidgetInfo,
} from './constants';
import {
getHostItemKey,
getHostRowKey,
hostColumns,
hostColumnsConfig,
hostRenderRowData,
} from './table.config';
import { getHostsQuickFiltersConfig } from './utils';
@@ -42,8 +42,8 @@ import styles from './InfraMonitoringHosts.module.scss';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
@@ -170,10 +170,10 @@ function Hosts(): JSX.Element {
<K8sBaseList
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.HOSTS}
tableColumnsDefinitions={hostColumns}
tableColumns={hostColumnsConfig}
fetchListData={fetchListData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
renderRowData={hostRenderRowData}
eventCategory={InfraMonitoringEvents.HostEntity}
/>
</div>

View File

@@ -0,0 +1,190 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useQueryBuilderOperations from 'hooks/queryBuilder/useQueryBuilderOperations';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import Hosts from '../Hosts';
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
minTime: 1713734400000,
maxTime: 1713738000000,
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
})),
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
minTime: 1713734400000000000,
maxTime: 1713738000000000000,
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
__esModule: true,
default: ({ onSelect, selectedTime, selectedValue }: any): JSX.Element => (
<div data-testid="custom-time-picker">
<button onClick={(): void => onSelect('custom')}>
{selectedTime} - {selectedValue}
</button>
</div>
),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.mock('react-router-dom', () => {
const ROUTES = jest.requireActual('constants/routes').default;
return {
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({
pathname: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
}),
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
timezone: {
offset: 0,
},
browserTimezone: {
offset: 0,
},
} as any);
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
statusCode: 200,
error: null,
message: 'Success',
payload: {
status: 'success',
data: {
type: 'list',
records: [
{
hostName: 'test-host',
active: true,
os: 'linux',
cpu: 0.75,
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
memory: 0.65,
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
wait: 0.03,
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15: 0.5,
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
},
],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
},
},
params: {} as any,
});
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
featureFlags: [],
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
} as any);
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
currentQuery: initialQueriesMap.metrics,
setSupersetQuery: jest.fn(),
setLastUsedQuery: jest.fn(),
handleSetConfig: jest.fn(),
resetQuery: jest.fn(),
updateAllQueriesOperators: jest.fn(),
} as any);
jest.spyOn(useQueryBuilderOperations, 'useQueryOperations').mockReturnValue({
handleChangeQueryData: jest.fn(),
} as any);
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('Hosts', () => {
beforeEach(() => {
queryClient.clear();
});
it('renders hosts list table', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<Hosts />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.ant-table')).toBeInTheDocument();
});
});
it('renders filters', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<Hosts />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,143 @@
import { render, screen } from '@testing-library/react';
import { HostData, TimeSeries } from 'api/infraMonitoring/getHostLists';
import { hostRenderRowData } from '../table.config';
import { getHostsQuickFiltersConfig, HostnameCell } from '../utils';
const emptyTimeSeries: TimeSeries = {
labels: {},
labelsArray: [],
values: [],
};
describe('InfraMonitoringHosts utils', () => {
describe('hostRenderRowData', () => {
it('should format host data correctly', () => {
const host: HostData = {
hostName: 'test-host',
active: true,
cpu: 0.95,
memory: 0.85,
wait: 0.05,
load15: 2.5,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
expect(result.wait).toBe('5%');
expect(result.load15).toBe(2.5);
expect(result.itemKey).toBe('test-host');
expect(result.hostName).toBe('test-host');
const activeTag = render(result.active as JSX.Element);
expect(activeTag.container.textContent).toBe('ACTIVE');
expect(activeTag.getByText('ACTIVE')).toBeTruthy();
const cpuProgress = render(result.cpu as JSX.Element);
expect(cpuProgress.container.querySelector('.ant-progress')).toBeTruthy();
const memoryProgress = render(result.memory as JSX.Element);
expect(memoryProgress.container.querySelector('.ant-progress')).toBeTruthy();
});
it('should handle inactive hosts', () => {
const host: HostData = {
hostName: 'test-host',
active: false,
cpu: 0.3,
memory: 0.4,
wait: 0.02,
load15: 1.2,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
const inactiveTag = render(result.active as JSX.Element);
expect(inactiveTag.container.textContent).toBe('INACTIVE');
expect(inactiveTag.getByText('INACTIVE')).toBeTruthy();
});
it('should use empty itemKey when host has no hostname', () => {
const host: HostData = {
hostName: '',
active: true,
cpu: 0.5,
memory: 0.4,
wait: 0.01,
load15: 1.0,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
expect(result.itemKey).toBe('');
});
});
describe('HostnameCell', () => {
it('should render hostname when present (case A: no icon)', () => {
const { container } = render(<HostnameCell hostName="gke-prod-1" />);
expect(container.querySelector('.hostname-column-value')).toBeTruthy();
expect(container.textContent).toBe('gke-prod-1');
expect(container.querySelector('.hostname-cell-missing')).toBeFalsy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeFalsy();
});
it('should render placeholder and icon when hostName is empty (case B)', () => {
const { container } = render(<HostnameCell hostName="" />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
const iconWrapper = container.querySelector('.hostname-cell-warning-icon');
expect(iconWrapper).toBeTruthy();
expect(iconWrapper?.getAttribute('aria-label')).toBe(
'Missing host.name metadata',
);
expect(iconWrapper?.getAttribute('tabindex')).toBe('0');
});
it('should render placeholder and icon when hostName is whitespace only (case C)', () => {
const { container } = render(<HostnameCell hostName=" " />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
it('should render placeholder and icon when hostName is undefined (case D)', () => {
const { container } = render(<HostnameCell hostName={undefined} />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
});
describe('getHostsQuickFiltersConfig', () => {
it('should return correct config when dotMetricsEnabled is true', () => {
const result = getHostsQuickFiltersConfig(true);
expect(result[0].attributeKey.key).toBe('host.name');
expect(result[1].attributeKey.key).toBe('os.type');
expect(result[0].aggregateAttribute).toBe('system.cpu.load_average.15m');
});
it('should return correct config when dotMetricsEnabled is false', () => {
const result = getHostsQuickFiltersConfig(false);
expect(result[0].attributeKey.key).toBe('host_name');
expect(result[1].attributeKey.key).toBe('os_type');
expect(result[0].aggregateAttribute).toBe('system_cpu_load_average_15m');
});
});
});

View File

@@ -1,22 +1,160 @@
import React from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tag, Tooltip } from 'antd';
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import { HostData } from 'api/infraMonitoring/getHostLists';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8s/Base/utils';
import { K8sRenderedRowData } from 'container/InfraMonitoringK8s/Base/types';
import { IEntityColumn } from 'container/InfraMonitoringK8s/Base/useInfraMonitoringTableColumnsStore';
import {
EntityProgressBar,
ExpandButtonWrapper,
ValidateColumnValueWrapper,
} from 'container/InfraMonitoringK8s/components';
getGroupByEl,
getGroupedByMeta,
getRowKey,
} from 'container/InfraMonitoringK8s/Base/utils';
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8s/commonUtils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8s/hooks';
import EntityGroupHeader from 'container/InfraMonitoringK8s/Base/EntityGroupHeader';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getMemoryProgressColor, getProgressColor } from './constants';
import { HostnameCell } from './utils';
import styles from './table.module.scss';
import { Container } from 'lucide-react';
export const hostColumns: IEntityColumn[] = [
{
label: 'Host group',
value: 'hostGroup',
id: 'hostGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Hostname',
value: 'hostName',
id: 'hostName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Status',
value: 'active',
id: 'active',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'IOWait',
value: 'wait',
id: 'wait',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Load Avg',
value: 'load15',
id: 'load15',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const hostColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> HOST GROUP
</div>
),
dataIndex: 'hostGroup',
key: 'hostGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div className={styles.hostnameColumnHeader}>Hostname</div>,
dataIndex: 'hostName',
key: 'hostName',
width: 250,
render: (_value, record): React.ReactNode => (
<HostnameCell
hostName={typeof record.hostName === 'string' ? record.hostName : ''}
/>
),
},
{
title: (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'active',
key: 'active',
width: 100,
},
{
title: <div className={styles.columnHeaderRight}>CPU Usage</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 100,
sorter: true,
align: 'right',
},
{
title: (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'memory',
key: 'memory',
width: 100,
sorter: true,
align: 'right',
},
{
title: <div className={styles.columnHeaderRight}>IOWait</div>,
dataIndex: 'wait',
key: 'wait',
width: 100,
sorter: true,
align: 'right',
},
{
title: <div className={styles.columnHeaderRight}>Load Avg</div>,
dataIndex: 'load15',
key: 'load15',
width: 100,
sorter: true,
align: 'right',
},
];
function hostRowSource(host: HostData): { meta: Record<string, string> } {
return {
@@ -30,154 +168,67 @@ function hostRowSource(host: HostData): { meta: Record<string, string> } {
};
}
export function getHostRowKey(host: HostData): string {
return host.hostName || 'unknown';
}
export const hostRenderRowData = (
host: HostData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => {
const synthetic = hostRowSource(host);
const rowKey = getRowKey(synthetic, () => host.hostName || 'unknown', groupBy);
const groupedByMeta = getGroupedByMeta(synthetic, groupBy);
const cpuPercent = Number((host.cpu * 100).toFixed(1));
const memoryPercent = Number((host.memory * 100).toFixed(1));
export function getHostItemKey(host: HostData): string {
return host.hostName ?? '';
}
function HostGroupCell({ row }: { row: HostData }): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
const synthetic = hostRowSource(row);
return getGroupByEl(synthetic, groupBy) as JSX.Element;
}
export const hostColumnsConfig: TableColumnDef<HostData>[] = [
{
id: 'hostGroup',
header: (): React.ReactNode => <EntityGroupHeader title="HOST GROUP" />,
accessorFn: (row): string => row.hostName ?? '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, isExpanded, toggleExpanded }): React.ReactNode => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<HostGroupCell row={row} />
</ExpandButtonWrapper>
return {
key: rowKey,
itemKey: host.hostName ?? '',
groupedByMeta,
meta: synthetic.meta,
hostGroup: getGroupByEl(synthetic, groupBy),
...synthetic.meta,
hostName: host.hostName ?? '',
active: (
<Tag
bordered
className={`${styles.statusTag} ${
host.active ? styles.statusTagActive : styles.statusTagInactive
}`}
>
{host.active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
),
},
{
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader title="Hostname" icon={<Container size={14} />} />
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<HostnameCell hostName={value as string} />
),
},
{
id: 'active',
header: (): React.ReactNode => (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
accessorFn: (row): boolean => row.active,
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const active = value as boolean;
return (
<Tag
bordered
className={`${styles.statusTag} ${
active ? styles.statusTagActive : styles.statusTagInactive
}`}
cpu: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.cpu}
entity={InfraMonitoringEntity.HOSTS}
>
{active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
);
},
},
{
id: 'cpu',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>CPU Usage</div>
),
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'memory',
header: (): React.ReactNode => (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<InfoCircleOutlined />
</Tooltip>
<Progress
percent={cpuPercent}
strokeLinecap="butt"
size="small"
strokeColor={getProgressColor(cpuPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
</div>
),
accessorFn: (row): number => row.memory,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.HOSTS}
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>IOWait</div>
memory: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.memory}
entity={InfraMonitoringEntity.HOSTS}
>
<Progress
percent={memoryPercent}
strokeLinecap="butt"
size="small"
strokeColor={getMemoryProgressColor(memoryPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
</div>
),
accessorFn: (row): number => row.wait,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const wait = value as number;
return (
<TanStackTable.Text>{`${Number((wait * 100).toFixed(1))}%`}</TanStackTable.Text>
);
},
},
{
id: 'load15',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>Load Avg</div>
),
accessorFn: (row): number => row.load15,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as number}</TanStackTable.Text>
),
},
];
wait: `${Number((host.wait * 100).toFixed(1))}%`,
load15: host.load15,
};
};

View File

@@ -1,21 +0,0 @@
import { Group } from 'lucide-react';
import styles from './EntityGroupHeader.module.scss';
interface EntityGroupHeaderProps {
title: string;
icon?: React.ReactNode;
}
function EntityGroupHeader({
title,
icon,
}: EntityGroupHeaderProps): JSX.Element {
return (
<div className={styles.entityGroupHeader}>
{icon || <Group size={14} data-hide-expanded="true" />} {title}
</div>
);
}
export default EntityGroupHeader;

View File

@@ -37,7 +37,10 @@ import {
X,
} from 'lucide-react';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -187,19 +190,16 @@ function K8sBaseDetails<T>({
);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const { startMs, endMs } = useMemo(() => {
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
return {
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
};
}, [getMinMaxTime, selectedTime]);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
[queryKeyPrefix, selectedItem, selectedTime],
);
const {

View File

@@ -1,40 +1,175 @@
.emptyStateContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 400px;
.clickableRow {
cursor: pointer;
}
.k8SListTable {
padding-left: var(--spacing-2);
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--k8s-base-list-pagination-offset: 64px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
display: flex;
flex-direction: column;
--tanstack-expansion-first-col-padding-left: 30px;
:global(.ant-spin-nested-loading) {
flex: 1;
display: flex;
flex-direction: column;
}
&[data-has-group-by='false'] {
--tanstack-cell-padding-left-first-column: 28px;
:global(.ant-spin-container) {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-bottom: var(--k8s-base-list-pagination-offset);
}
:global(.ant-table-container) {
flex: 1;
display: flex;
flex-direction: column;
}
:global(.ant-table-header) {
flex-shrink: 0;
}
:global(.ant-table-body) {
flex: 1;
min-height: 0;
overflow: auto !important;
}
:global(.ant-table) {
flex: 1;
background: var(--l1-background) !important;
:global(.ant-table-thead > tr > th) {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: var(--l1-background) !important;
&::before {
background: transparent !important;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--l1-background);
border-bottom: none;
&:before,
&:after {
display: none;
}
}
:global(.progress-container) {
:global(.ant-progress-bg) {
height: 8px !important;
border-radius: 4px;
}
}
:global(.ant-table-tbody > tr:hover > td) {
background: var(--l1-background-hover);
}
:global(.ant-table-tbody > tr:not(.ant-table-expanded-row):hover > td) {
background: var(--l1-background-hover) !important;
}
:global(.ant-table-tbody > tr.ant-table-expanded-row:hover > td) {
background: var(--l1-background);
}
:global(.ant-table-tbody > tr > td) {
border-bottom: none;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
:global(.ant-table-cell) {
min-width: 180px !important;
max-width: 180px !important;
}
:global(.ant-table-cell:first-of-type):not(
:global(.ant-table-row-expand-icon-cell)
) {
min-width: 250px !important;
max-width: 250px !important;
}
:global(.ant-table-row-expand-icon-cell) {
min-width: 40px !important;
max-width: 40px !important;
}
:global(.ant-table-content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: color-mix(var(--accent-primary), transparent 40%);
}
}
}
}
.paginationContainer {
padding-bottom: var(--spacing-8);
padding-right: var(--spacing-8);
.paginationDock {
position: fixed;
bottom: 0;
right: 0;
width: calc(100% - 340px) !important;
margin: 0 !important;
padding: 16px;
background-color: var(--l1-background);
z-index: 1;
ul {
margin: 0;
:global(.ant-pagination-item) {
border-radius: 4px;
}
:global(.ant-pagination-item-active) {
background: var(--l2-background);
border-color: var(--l2-border);
a {
color: var(--l1-foreground) !important;
}
}
}

View File

@@ -1,36 +1,48 @@
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Typography } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { InfraMonitoringEntity } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPageSizeListing,
} from '../hooks';
import { usePageSize } from '../utils';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sHeader from './K8sHeader';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import {
IEntityColumn,
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
@@ -41,13 +53,11 @@ export type K8sBaseListEmptyStateContext = {
rawData?: unknown;
};
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> };
export type K8sBaseListProps<T extends K8sEntityData> = {
export type K8sBaseListProps<T = unknown> = {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
tableColumnsDefinitions: IEntityColumn[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -57,63 +67,69 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
};
export function K8sBaseList<T extends K8sEntityData>({
export function K8sBaseList<T>({
controlListPrefix,
entity,
tableColumnsDefinitions,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
renderRowData,
eventCategory,
renderEmptyState,
}: K8sBaseListProps<T>): JSX.Element {
const [queryFilters] = useInfraMonitoringFiltersK8s();
const [currentPage] = useInfraMonitoringPageListing();
const [currentPageSize] = useInfraMonitoringPageSizeListing();
const [queryFilters] = useInfraMonitoringFilters();
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [initialOrderBy] = useState(orderBy);
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
useEffect(() => {
setExpandedRowKeys([]);
}, [groupBy, currentPage]);
const { pageSize, setPageSize } = usePageSize(entity);
const initializeTableColumns = useInfraMonitoringTableColumnsStore(
(state) => state.initializePageColumns,
);
useEffect(() => {
initializeTableColumns(entity, tableColumnsDefinitions);
}, [initializeTableColumns, entity, tableColumnsDefinitions]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(currentPageSize),
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
currentPageSize,
pageSize,
currentPage,
queryFilters,
orderBy,
@@ -127,8 +143,8 @@ export function K8sBaseList<T extends K8sEntityData>({
return fetchListData(
{
limit: currentPageSize,
offset: (currentPage - 1) * currentPageSize,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters || { items: [], op: 'AND' },
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
@@ -141,14 +157,62 @@ export function K8sBaseList<T extends K8sEntityData>({
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const pageData = data?.data ?? [];
const pageData = data?.data;
const totalCount = data?.total || 0;
const hasFilters = (queryFilters?.items?.length ?? 0) > 0;
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],
);
const formattedItemsData = useMemo(() => {
if (!pageData) {
return undefined;
}
const rows = pageData.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
// eslint-disable-next-line sonarjs/no-identical-functions
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [pageData, renderRowData, groupBy]);
const handleTableChange: TableProps<K8sRenderedRowData>['onChange'] =
useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter:
| SorterResult<K8sRenderedRowData>
| SorterResult<K8sRenderedRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[eventCategory, setCurrentPage, setOrderBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
@@ -159,139 +223,214 @@ export function K8sBaseList<T extends K8sEntityData>({
});
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length === 0) {
setSelectedItem(itemKey);
}
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
const openItemInNewTab = (record: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', record.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sRenderedRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openItemInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedItem(record.itemKey);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const [columnsDefinitions, columnsHidden] =
useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsOnList = useMemo(
() =>
columnsDefinitions
.filter(
(col) =>
(groupBy?.length > 0 && col.behavior === 'hidden-on-expand') ||
(!groupBy?.length && col.behavior === 'hidden-on-collapse'),
)
.map((col) => col.id),
[columnsDefinitions, groupBy?.length],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length > 0) {
return;
const mapDefaultSort = useCallback(
(
tableColumn: ColumnType<K8sRenderedRowData>,
): ColumnType<K8sRenderedRowData> => {
if (tableColumn.key === initialOrderBy?.columnName) {
return {
...tableColumn,
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
};
}
// Build URL with selectedItem param
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
openInNewTab(url.pathname + url.search);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
return tableColumn;
},
[eventCategory, groupBy.length],
[initialOrderBy?.columnName, initialOrderBy?.order],
);
const columns = useMemo(
() =>
tableColumns
.filter(
(c) =>
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
!columnsHidden.includes(c.key?.toString() || ''),
)
.map(mapDefaultSort),
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
);
const isGroupedByAttribute = groupBy.length > 0;
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
[tableColumns, hiddenColumnIds],
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
<K8sExpandedRow<T>
record={record}
entity={entity}
tableColumns={tableColumns}
fetchListData={fetchListData}
renderRowData={renderRowData}
/>
);
const renderExpandedRow = useCallback(
(
_record: T,
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
tableColumns={expandedRowColumns}
fetchListData={fetchListData}
getRowKey={getRowKey}
getItemKey={getItemKey}
/>
),
[entity, fetchListData, getRowKey, getItemKey, expandedRowColumns],
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sRenderedRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sRenderedRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
const getRowCanExpand = useCallback(
(): boolean => isGroupedByAttribute,
[isGroupedByAttribute],
);
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
isError,
const emptyStateContext: K8sBaseListEmptyStateContext = {
isError: isError || !!data?.error,
error: data?.error,
totalCount,
hasFilters,
isLoading: showTableLoadingState,
rawData: data?.rawData,
}) || (
};
const emptyTableMessage: React.ReactNode = renderEmptyState?.(
emptyStateContext,
) || (
<K8sEmptyState
isError={isError}
error={data?.error}
isLoading={showTableLoadingState}
rawData={data?.rawData}
isError={emptyStateContext.isError}
error={emptyStateContext.error}
isLoading={emptyStateContext.isLoading}
rawData={emptyStateContext.rawData}
/>
);
const showEmptyState = !showTableLoadingState && pageData.length === 0;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isLoading={showTableLoadingState}
getRowKey={getRowKey}
getItemKey={getItemKey}
groupBy={groupBy}
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable, expandedRowColumns)}
enableQueryParams={{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
orderBy: INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
expanded: INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED,
}}
pagination={{
total: totalCount,
defaultLimit: 10,
defaultPage: 1,
showTotalCount: true,
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
}}
paginationClassname={styles.paginationContainer}
/>
)}
<Table
className={styles.k8SListTable}
dataSource={showTableLoadingState ? [] : formattedItemsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
className: styles.paginationDock,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : emptyTableMessage,
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: styles.clickableRow,
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
</>
);
}

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