Compare commits
61 Commits
fix/uplot-
...
nv/area-ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e637bbba02 | ||
|
|
dca6aa497d | ||
|
|
bb47550c01 | ||
|
|
ecf510ed67 | ||
|
|
5b94d79e46 | ||
|
|
60a6a5b38c | ||
|
|
b47245d46a | ||
|
|
52ec7bf128 | ||
|
|
afe62d77b0 | ||
|
|
1aa6346a4c | ||
|
|
0f3b3dfb07 | ||
|
|
7e2cd441f2 | ||
|
|
098448330d | ||
|
|
eb01617c15 | ||
|
|
7bcfaab35e | ||
|
|
5b62b31d34 | ||
|
|
f6a9b4b1f6 | ||
|
|
b46f099966 | ||
|
|
fcfc1923c3 | ||
|
|
dc836bb67c | ||
|
|
b86e536432 | ||
|
|
a76a7ede70 | ||
|
|
cfc7a04bc8 | ||
|
|
014cab564e | ||
|
|
9cf2ffe000 | ||
|
|
81d1716c7e | ||
|
|
a7935ca668 | ||
|
|
30823c520d | ||
|
|
8e4aedcecd | ||
|
|
1c7fd32f91 | ||
|
|
fb106b3253 | ||
|
|
edb63ae7be | ||
|
|
e7dc01de45 | ||
|
|
c40ebb027b | ||
|
|
789a4626fc | ||
|
|
2dcd4d9a66 | ||
|
|
abf60c0af3 | ||
|
|
ebc8d86a8d | ||
|
|
0cf3988867 | ||
|
|
abff2aefd8 | ||
|
|
5b3b2865d1 | ||
|
|
a7fd14eac9 | ||
|
|
faaed20dbd | ||
|
|
b5851ce388 | ||
|
|
35d1869314 | ||
|
|
fe2200e887 | ||
|
|
52a9a893c2 | ||
|
|
013e631c68 | ||
|
|
535a29adbf | ||
|
|
3d5aab744d | ||
|
|
a8309a1a02 | ||
|
|
897036968c | ||
|
|
55ef5fbc3c | ||
|
|
49749626dc | ||
|
|
061eb1f867 | ||
|
|
092e0b7d99 | ||
|
|
16849967c5 | ||
|
|
c8e7685f06 | ||
|
|
a3caaaf7f2 | ||
|
|
a355996a5d | ||
|
|
eea11972a9 |
1391
docs/api/openapi.yml
@@ -349,7 +349,7 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
|
||||
|
||||
### `oneOf` with a discriminator
|
||||
|
||||
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`). How to model the sum type itself is covered in [types.md](types.md#sum-types-the-kindspec-envelope) — this section is only about its schema.
|
||||
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
|
||||
|
||||
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.
|
||||
|
||||
|
||||
@@ -61,31 +61,37 @@ type Channel struct {
|
||||
|
||||
```go
|
||||
type AuthDomain struct {
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
authDomainConfig *AuthDomainConfig
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
storableAuthDomainConfig *StorableAuthDomainConfig
|
||||
}
|
||||
|
||||
type StorableAuthDomain struct {
|
||||
bun.BaseModel `bun:"table:auth_domain"`
|
||||
types.Identifiable
|
||||
Name string `bun:"name"`
|
||||
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
|
||||
Data string `bun:"data"` // StorableAuthDomainConfig serialized as JSON
|
||||
OrgID valuer.UUID `bun:"org_id"`
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
type PostableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config" required:"true"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type UpdateableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"` // Name intentionally absent
|
||||
type UpdatableAuthDomain struct {
|
||||
Enabled bool `json:"enabled"` // Name intentionally absent
|
||||
Config AuthDomainConfig `json:"config" required:"true"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
*StorableAuthDomain
|
||||
*AuthDomainConfig
|
||||
StorableAuthDomain
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
```
|
||||
@@ -93,11 +99,11 @@ type GettableAuthDomain struct {
|
||||
Each flavor exists for a concrete reason:
|
||||
|
||||
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
|
||||
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
|
||||
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
|
||||
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request; `AuthDomainConfig` is a kind/spec envelope.
|
||||
- `UpdatableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
|
||||
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
|
||||
|
||||
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
|
||||
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `storableAuthDomainConfig` — and owns business methods such as `Update(updatable)` and `Patch(patchable)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromPostableAuthDomain`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
|
||||
|
||||
## Sum types: the kind/spec envelope
|
||||
|
||||
|
||||
@@ -53,10 +53,6 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
|
||||
}
|
||||
|
||||
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -85,6 +81,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
oidcConfig, err := authDomain.Config().OIDCConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -106,14 +107,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
|
||||
if claims == nil && oidcConfig.GetUserInfo {
|
||||
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
|
||||
emailClaim, ok := claims[oidcConfig.ClaimMapping.Email].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
|
||||
}
|
||||
@@ -123,7 +124,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
|
||||
if !oidcConfig.InsecureSkipEmailVerified {
|
||||
emailVerifiedClaim, ok := claims["email_verified"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
|
||||
@@ -135,14 +136,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
|
||||
if nameClaim := oidcConfig.ClaimMapping.Name; nameClaim != "" {
|
||||
if n, ok := claims[nameClaim].(string); ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
|
||||
if groupsClaim := oidcConfig.ClaimMapping.Groups; groupsClaim != "" {
|
||||
if claimValue, exists := claims[groupsClaim]; exists {
|
||||
switch g := claimValue.(type) {
|
||||
case []any:
|
||||
@@ -161,7 +162,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
|
||||
if roleClaim := oidcConfig.ClaimMapping.Role; roleClaim != "" {
|
||||
if r, ok := claims[roleClaim].(string); ok {
|
||||
role = r
|
||||
}
|
||||
@@ -177,11 +178,16 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
}
|
||||
|
||||
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
|
||||
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
|
||||
oidcConfig, err := authDomain.Config().OIDCConfig()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
|
||||
if oidcConfig.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, oidcConfig.IssuerAlias)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -189,13 +195,13 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
|
||||
scopes := make([]string, len(defaultScopes))
|
||||
copy(scopes, defaultScopes)
|
||||
|
||||
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
|
||||
if authDomain.RoleMapping() != nil && len(authDomain.RoleMapping().GroupMappings) > 0 {
|
||||
scopes = append(scopes, "groups")
|
||||
}
|
||||
|
||||
return oidcProvider, &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
|
||||
ClientID: oidcConfig.ClientID,
|
||||
ClientSecret: oidcConfig.ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
@@ -212,7 +218,12 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
|
||||
oidcConfig, err := authDomain.Config().OIDCConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())
|
||||
|
||||
@@ -40,10 +40,6 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
|
||||
}
|
||||
|
||||
sp, err := a.serviceProvider(siteURL, authDomain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -73,6 +69,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
samlConfig, err := authDomain.Config().SamlConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sp, err := a.serviceProvider(state.URL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,19 +102,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
|
||||
if nameAttribute := samlConfig.AttributeMapping.Name; nameAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
|
||||
name = val
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
|
||||
if groupAttribute := samlConfig.AttributeMapping.Groups; groupAttribute != "" {
|
||||
groups = assertionInfo.Values.GetAll(groupAttribute)
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
|
||||
if roleAttribute := samlConfig.AttributeMapping.Role; roleAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
|
||||
role = val
|
||||
}
|
||||
@@ -131,7 +132,12 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
}
|
||||
|
||||
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
|
||||
certStore, err := a.getCertificateStore(authDomain)
|
||||
samlConfig, err := authDomain.Config().SamlConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certStore, err := a.getCertificateStore(samlConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,32 +148,32 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
|
||||
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
|
||||
// For AWSSSO, this is the value of Application SAML audience.
|
||||
return &saml2.SAMLServiceProvider{
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
|
||||
IdentityProviderSSOURL: samlConfig.Location,
|
||||
IdentityProviderIssuer: samlConfig.EntityID,
|
||||
ServiceProviderIssuer: siteURL.Host,
|
||||
AssertionConsumerServiceURL: acsURL.String(),
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
|
||||
SignAuthnRequests: !samlConfig.InsecureSkipAuthNRequestsSigned,
|
||||
AllowMissingAttributes: true,
|
||||
IDPCertificateStore: certStore,
|
||||
SPKeyStore: dsig.RandomKeyStoreForTest(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
|
||||
func (a *AuthN) getCertificateStore(samlConfig authtypes.SamlConfig) (dsig.X509CertificateStore, error) {
|
||||
certStore := &dsig.MemoryX509CertificateStore{
|
||||
Roots: []*x509.Certificate{},
|
||||
}
|
||||
|
||||
var certBytes []byte
|
||||
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
|
||||
if strings.Contains(samlConfig.Certificate, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(samlConfig.Certificate))
|
||||
if block == nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
|
||||
}
|
||||
|
||||
certBytes = block.Bytes
|
||||
} else {
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
|
||||
certData, err := base64.StdEncoding.DecodeString(samlConfig.Certificate)
|
||||
if err != nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -183,7 +183,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterInfraMetricsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
|
||||
@@ -487,8 +487,11 @@
|
||||
// Simplifies boolean returns
|
||||
"sonarjs/prefer-while": "error",
|
||||
// Suggests while loops over for loops
|
||||
"sonarjs/elseif-without-else": "off"
|
||||
"sonarjs/elseif-without-else": "off",
|
||||
// Requires final else in if-else-if chains (was disabled)
|
||||
"signoz/no-conditional-text-nodes-with-siblings": "warn",
|
||||
// Vendored from eslint-plugin-react-google-translate
|
||||
"signoz/no-return-text-nodes": "warn"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"src/parser/*.ts",
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"lint:generated": "oxlint ./src/api/generated --fix",
|
||||
"lint:fix": "oxlint ./src --fix",
|
||||
"lint:styles": "stylelint \"src/**/*.scss\"",
|
||||
"test:plugins": "node --test \"plugins/__tests__/*.test.mjs\"",
|
||||
"jest": "jest",
|
||||
"jest:coverage": "jest --coverage",
|
||||
"jest:watch": "jest --watch",
|
||||
@@ -87,6 +88,7 @@
|
||||
"jest": "30.2.0",
|
||||
"js-base64": "^3.7.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"monaco-editor": "0.55.1",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
@@ -125,6 +127,7 @@
|
||||
"rrule": "2.8.1",
|
||||
"styled-components": "^5.3.11",
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"translation-resilience": "^0.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"uplot": "1.6.31",
|
||||
"uuid": "14.0.1",
|
||||
@@ -194,7 +197,7 @@
|
||||
"oxfmt": "0.54.0",
|
||||
"oxlint": "1.69.0",
|
||||
"oxlint-tsgolint": "0.23.0",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.26",
|
||||
"postcss-scss": "4.0.9",
|
||||
"react-resizable": "3.0.4",
|
||||
"redux-mock-store": "1.5.4",
|
||||
@@ -235,4 +238,4 @@
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
130
frontend/plugins/__tests__/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Plugin rule tests
|
||||
|
||||
Tests for the custom oxlint rules in `plugins/rules/`.
|
||||
|
||||
```bash
|
||||
pnpm test:plugins
|
||||
```
|
||||
|
||||
Runs on `node --test` rather than jest. The jest config is built for application
|
||||
code — jsdom, ts-jest ESM transforms, a large `transformIgnorePatterns` wall —
|
||||
and none of it applies to a suite whose only job is to shell out to the linter.
|
||||
|
||||
## Why it drives the real binary
|
||||
|
||||
Each case is written to a temp file and linted by the actual `oxlint` binary,
|
||||
with every builtin category switched off so the only diagnostics that can appear
|
||||
belong to the rule under test. Assertions therefore describe what CI enforces.
|
||||
|
||||
The alternative — walking the AST in-process — would need a stand-in for
|
||||
oxlint's JS plugin AST. That AST is ESTree-shaped but not ESTree, and it carries
|
||||
no type information, so a stand-in would drift from the runtime it claims to
|
||||
model and the tests would certify behaviour that never happens.
|
||||
|
||||
All cases in a suite share one `oxlint` invocation and are mapped back by
|
||||
filename. Per-case spawning costs roughly 80ms each; batching keeps both suites
|
||||
together at around 250ms.
|
||||
|
||||
## Adding a suite
|
||||
|
||||
```js
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-navigator-clipboard',
|
||||
valid: ['const x = 1;'],
|
||||
invalid: [
|
||||
{
|
||||
code: 'navigator.clipboard.writeText("x");',
|
||||
errors: [{ message: 'useCopyToClipboard', line: 1, column: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
`ruleTester` must be awaited at the top level — it loads the plugin and runs
|
||||
`oxlint` before declaring the tests.
|
||||
|
||||
- `rule` — the key the plugin exports it under. `plugin` defaults to
|
||||
`plugins/signoz.mjs`; pass a path relative to `frontend/` for another plugin.
|
||||
- Cases are `.tsx` unless a `filename` gives another extension.
|
||||
- `errors` takes a count or an array. Each entry may assert `message` (substring
|
||||
or `RegExp`), `line` and `column`; omitted fields are not checked.
|
||||
- `name` labels the case in the output and defaults to its first line of code.
|
||||
- `output` asserts the source after suggestions are applied — see below.
|
||||
- `todo` marks a case as a known defect — see below.
|
||||
|
||||
## Suggestions
|
||||
|
||||
Both Google Translate rules attach their wrap as a *suggestion*, not a fix, so
|
||||
`--fix` leaves the code alone and `--fix-suggestions` applies it. The wrap is
|
||||
`<span className="translate-safe">`, and `.translate-safe` is `display: contents`
|
||||
in `src/styles.scss`: React owns an element that absorbs Translate's `<font>`
|
||||
swap, while the box tree stays as it was, so a flex or grid parent still sees one
|
||||
contiguous text run rather than a new item with its own `gap`.
|
||||
|
||||
It stays a suggestion because the element is still a DOM child even with no box:
|
||||
`> *`, `:nth-child` and sibling selectors still count it, and a component that
|
||||
inspects its children — `React.Children.map`, antd `Tooltip`, `Space` — sees an
|
||||
element where a string used to be. That is what oxlint means by "May change
|
||||
program behavior" in `--fix-suggestions --help`.
|
||||
|
||||
An invalid case carrying `output` is linted twice: once for diagnostics, and
|
||||
once with `--fix-suggestions` over an untouched copy of the same files. The
|
||||
second run costs one extra `oxlint` spawn per suite and only happens when at
|
||||
least one case asks for it.
|
||||
|
||||
```js
|
||||
{
|
||||
code: "export const A = () => <div>{f ? 'a' : 'b'}<b/></div>;",
|
||||
errors: 2,
|
||||
output:
|
||||
'export const A = () => <div>{f ? <span className="translate-safe">a</span> : <span className="translate-safe">b</span>}<b/></div>;',
|
||||
}
|
||||
```
|
||||
|
||||
Suggestions do not reformat, so a real run is `oxlint --fix-suggestions` then
|
||||
`oxfmt`.
|
||||
|
||||
## Known defects
|
||||
|
||||
A case carrying `todo` asserts what the rule *should* do. It still runs, but a
|
||||
failure is reported as a todo rather than failing the suite, so a bug can be
|
||||
pinned as an executable spec instead of prose. Fixing the rule turns the todo
|
||||
green; deleting the flag then makes it a regression guard.
|
||||
|
||||
Cases are prefixed `FP:` where the rule reports something it should not, `GAP:`
|
||||
where it misses something it should catch, and `TYPE-AWARE:` where the miss is
|
||||
only fixable once the linter can resolve types. Everything without a flag is a
|
||||
characterisation test recording current behaviour.
|
||||
|
||||
The current todos:
|
||||
|
||||
**Gaps — constructs the rules never visit.** `isProblematicConditional` requires
|
||||
a `JSXElement` parent, so a conditional inside a fragment is never inspected even
|
||||
though the failure does not care about the parent's kind. `no-return-text-nodes`
|
||||
listens only for `FunctionDeclaration` and reads the name off `node.id`, so
|
||||
arrow-function components and anonymous default exports are invisible — this
|
||||
codebase writes components as arrow functions, which is why that rule reports
|
||||
nothing across `src`.
|
||||
|
||||
Class components are left out deliberately rather than pinned as a gap: there
|
||||
are none in `src`.
|
||||
|
||||
**Type-aware gaps.** Upstream resolves branch types through
|
||||
`@typescript-eslint/utils` and reports anything typed `string` or `number`.
|
||||
oxlint's JS plugin runtime exposes no type information — `sourceCode.parserServices`
|
||||
is always `{}` — so those code paths were removed rather than left dormant. The
|
||||
`TYPE-AWARE:` todos record what they used to catch, and become the acceptance
|
||||
criteria if oxlint ever hands types to JS plugins.
|
||||
|
||||
## Not a defect
|
||||
|
||||
Without types, `no-conditional-text-nodes-with-siblings` falls back to a callee
|
||||
allowlist (`t`, `formatMessage`, `toString`, `toLocaleString`). Cases around
|
||||
that allowlist pin its edges; widening it is the supported way to catch more
|
||||
call expressions.
|
||||
|
||||
Both branches of a ternary are reported separately, so one fix can clear two
|
||||
diagnostics. That inflates the count but every reported node is genuinely a text
|
||||
node, so the cases assert both.
|
||||
@@ -0,0 +1,302 @@
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
const CONDITIONAL = 'Conditionally rendered text nodes with siblings';
|
||||
const PRECEDED = 'Text nodes which are preceded by a conditional expression';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-conditional-text-nodes-with-siblings',
|
||||
valid: [
|
||||
{
|
||||
name: 'conditional text node without siblings',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'boolean branches are not text',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? true : false}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'null branches are not text',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? null : null}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'element branches are already wrapped',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <b>y</b> : <i>n</i>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'text node before the conditional is safe',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\tleading text\n\t\t{flag && <b>y</b>}\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'member expression on the condition side is not rendered',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{obj.name && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'binary comparison on the condition side is not rendered',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{obj.name === 'x' && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
},
|
||||
// An empty string renders no text node at all, so there is nothing for
|
||||
// Google Translate to wrap and nothing for React to lose. Reporting it used
|
||||
// to be the rule's most common false positive.
|
||||
{
|
||||
name: 'element branch with an empty-string fallback',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? <b>Free Trial</b> : ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'both branches empty',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? '' : ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'logical and with an empty-string right-hand side',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag && ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
|
||||
// A template literal is checked the same way as the quoted form, so `{' '}`
|
||||
// and ``{` `}`` agree.
|
||||
{
|
||||
name: 'empty template literal branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? `` : ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only template literal branch is skipped',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? ` ` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'string literal branches with an element sibling',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 19 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">yes</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'logical and with a string right-hand side',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag && 'yes'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 12 }],
|
||||
},
|
||||
{
|
||||
name: 'numeric literals render as text',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? 1 : 2}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 15 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{1}</span> : <span className="translate-safe">{2}</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'template literal branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? `yes ${n}` : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 24 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{`yes ${n}`}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'member expression branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? obj.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 22 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{obj.name}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'a string needing escapes stays inside braces',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? "it\'s" : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{"it\'s"}</span> : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'optional chaining branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? obj?.deep?.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 29 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'nested ternary reports every text branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{a ? (b ? 'x' : 'y') : 'z'}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 13 },
|
||||
{ message: CONDITIONAL, line: 3, column: 19 },
|
||||
{ message: CONDITIONAL, line: 3, column: 26 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'static text following a conditional',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\ttrailing text\n\t</div>\n);',
|
||||
errors: [{ message: PRECEDED, line: 3, column: 21 }],
|
||||
// Only the visible run is wrapped; the surrounding newlines and tabs are
|
||||
// formatting and must stay outside the element.
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">trailing text</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'conditional text plus trailing static text reports both kinds',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? 'a' : 'b'}\n\t\tliteral tail\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 17 },
|
||||
{ message: PRECEDED, line: 3, column: 21 },
|
||||
],
|
||||
},
|
||||
|
||||
// The callee allowlist below is the untyped fallback. Without type
|
||||
// information the rule can only recognise known string-returning helpers,
|
||||
// so `t()` and `formatMessage()` are reported while an arbitrary call is
|
||||
// not. These cases pin that boundary.
|
||||
{
|
||||
name: 't() branch is reported via the callee allowlist',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? t('key') : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'formatMessage() branch is reported via the callee allowlist',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? formatMessage({id:'k'}) : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 37 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'arbitrary call is not recognised, only the literal branch reports',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 25 }],
|
||||
},
|
||||
{
|
||||
name: 'bare identifier is not recognised, only the literal branch reports',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 18 }],
|
||||
},
|
||||
{
|
||||
name: 'toString() branch is reported',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 28 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'toLocaleString() branch is reported',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toLocaleString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 34 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 't() in its own container following a conditional',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{t('key')}\n\t</div>\n);",
|
||||
errors: [{ message: PRECEDED, line: 4, column: 4 }],
|
||||
},
|
||||
{
|
||||
name: 'toString() in its own container following a conditional',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{val.toString()}\n\t</div>\n);',
|
||||
errors: [{ message: PRECEDED, line: 4, column: 4 }],
|
||||
// The whole container is replaced, so the result is not `{<span>{…}</span>}`.
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">{val.toString()}</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only string branch is skipped, the other branch reports',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? ' ' : 'x'}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 17 }],
|
||||
},
|
||||
{
|
||||
name: 'template literal holding an expression is not blank',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? `${n}` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
|
||||
},
|
||||
|
||||
// Upstream resolves branch types through `@typescript-eslint/utils` and
|
||||
// reports anything typed `string` or `number`. oxlint's JS plugin runtime
|
||||
// exposes no type information, so those paths were dropped and only the
|
||||
// callee allowlist remains. Kept as todos: if oxlint ever hands types to JS
|
||||
// plugins these become the acceptance criteria.
|
||||
{
|
||||
todo: 'needs type information to know the call returns a string',
|
||||
name: 'TYPE-AWARE: call returning a string',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
todo: 'needs type information to know the identifier is a string',
|
||||
name: 'TYPE-AWARE: identifier holding a string',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
todo: 'needs type information to know the identifier is a string',
|
||||
name: 'TYPE-AWARE: string identifier following a conditional',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{label}\n\t</div>\n);',
|
||||
errors: [{ message: PRECEDED, line: 4, column: 4 }],
|
||||
},
|
||||
|
||||
// `isChildOfJSXElement` matches only `JSXElement`, so a fragment parent is
|
||||
// never inspected. The Google Translate failure does not care whether the
|
||||
// parent is an element or a fragment.
|
||||
{
|
||||
todo: 'fragment parents are never inspected',
|
||||
name: 'GAP: conditional text with a sibling inside a fragment',
|
||||
code:
|
||||
"export const A = () => (\n\t<>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 19 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
162
frontend/plugins/__tests__/no-return-text-nodes.test.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
const RETURNS_TEXT = 'React components should avoid returning text nodes';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-return-text-nodes',
|
||||
valid: [
|
||||
{
|
||||
name: 'lowercase function is not a component',
|
||||
code: "export function foo() {\n\treturn 'text';\n}",
|
||||
},
|
||||
{
|
||||
name: 'returning JSX',
|
||||
code: 'export function Foo() {\n\treturn <div>hi</div>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'returning null',
|
||||
code: 'export function Foo() {\n\treturn null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'returning boolean',
|
||||
code: 'export function Foo() {\n\treturn true;\n}',
|
||||
},
|
||||
{ name: 'bare return', code: 'export function Foo() {\n\treturn;\n}' },
|
||||
{
|
||||
name: 'returning a variable is not a literal',
|
||||
code: "export function Foo() {\n\tconst s = 'x';\n\treturn s;\n}",
|
||||
},
|
||||
{
|
||||
name: 'lowercase nested function inside a component',
|
||||
code:
|
||||
"export function Foo() {\n\tfunction helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
},
|
||||
|
||||
{
|
||||
// The repo has no class components, so this is out of scope rather than
|
||||
// a gap worth closing.
|
||||
name: 'class method',
|
||||
code: "export class Foo {\n\trender() {\n\t\treturn 'text';\n\t}\n}",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'string literal',
|
||||
code: "export function Foo() {\n\treturn 'text';\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
output:
|
||||
'export function Foo() {\n\treturn <span className="translate-safe">{\'text\'}</span>;\n}',
|
||||
},
|
||||
{
|
||||
// JSX does not parse in a `.ts` file, so no suggestion is offered there.
|
||||
name: 'string literal in a non-JSX file',
|
||||
filename: 'case.ts',
|
||||
code: "export function Foo() {\n\treturn 'text';\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
output: "export function Foo() {\n\treturn 'text';\n}",
|
||||
},
|
||||
{
|
||||
name: 'numeric literal',
|
||||
code: 'export function Foo() {\n\treturn 42;\n}',
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
{
|
||||
name: 'template literal',
|
||||
code: 'export function Foo() {\n\treturn `text ${x}`;\n}',
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
{
|
||||
name: 'inside an if consequent',
|
||||
code:
|
||||
"export function Foo() {\n\tif (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside an else block',
|
||||
code:
|
||||
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else {\n\t\treturn 'x';\n\t}\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside an else-if chain',
|
||||
code:
|
||||
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else if (b) {\n\t\treturn 'x';\n\t}\n\treturn null;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a switch case',
|
||||
code:
|
||||
"export function Foo() {\n\tswitch (a) {\n\t\tcase 1:\n\t\t\treturn 'x';\n\t\tdefault:\n\t\t\treturn <div/>;\n\t}\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 4, column: 4 }],
|
||||
},
|
||||
{
|
||||
name: 'inside try, catch and finally',
|
||||
code:
|
||||
"export function Foo() {\n\ttry {\n\t\treturn 'a';\n\t} catch {\n\t\treturn 'b';\n\t} finally {\n\t\treturn 'c';\n\t}\n}",
|
||||
errors: [
|
||||
{ message: RETURNS_TEXT, line: 3, column: 3 },
|
||||
{ message: RETURNS_TEXT, line: 5, column: 3 },
|
||||
{ message: RETURNS_TEXT, line: 7, column: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'inside a for loop',
|
||||
code:
|
||||
"export function Foo() {\n\tfor (let i = 0; i < 3; i++) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a for-of loop',
|
||||
code:
|
||||
"export function Foo() {\n\tfor (const i of list) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a for-in loop',
|
||||
code:
|
||||
"export function Foo() {\n\tfor (const k in obj) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a while loop',
|
||||
code:
|
||||
"export function Foo() {\n\twhile (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a do-while loop',
|
||||
code: "export function Foo() {\n\tdo {\n\t\treturn 'x';\n\t} while (a);\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'capitalised nested function is treated as a component',
|
||||
code:
|
||||
"export function Foo() {\n\tfunction Helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
|
||||
// The rule listens only for `FunctionDeclaration` and reads the component
|
||||
// name off `node.id`. Everything below returns a text node from something
|
||||
// React renders as a component, and none of it is reported. This codebase
|
||||
// writes components as arrow functions, which is why the rule currently
|
||||
// finds nothing in `src`.
|
||||
{
|
||||
todo: 'arrow function components are never visited',
|
||||
name: 'GAP: arrow component with an expression body',
|
||||
code: "export const Foo = () => 'text';",
|
||||
errors: 1,
|
||||
},
|
||||
{
|
||||
todo: 'arrow function components are never visited',
|
||||
name: 'GAP: arrow component with a block body',
|
||||
code: "export const Foo = () => {\n\treturn 'text';\n};",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
{
|
||||
todo: 'anonymous declarations have no node.id to read a name from',
|
||||
name: 'GAP: anonymous default-exported component',
|
||||
code: "export default function () {\n\treturn 'text';\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
257
frontend/plugins/__tests__/rule-tester.mjs
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Test harness for oxlint JS plugins.
|
||||
*
|
||||
* Rules are exercised through the real `oxlint` binary rather than a hand-rolled
|
||||
* AST walker, so what the tests assert is exactly what CI enforces. oxlint's JS
|
||||
* plugin AST is close to ESTree but not identical, and it exposes no type
|
||||
* information, so any in-process fake would drift from the real runtime.
|
||||
*
|
||||
* All cases in a suite are written to one temp directory and linted in a single
|
||||
* oxlint invocation, then mapped back by filename. Spawning per case costs ~80ms
|
||||
* each; batching keeps a full suite under a second.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const FRONTEND_DIR = path.resolve(fileURLToPath(import.meta.url), '../../..');
|
||||
const OXLINT_BIN = path.join(FRONTEND_DIR, 'node_modules/.bin/oxlint');
|
||||
|
||||
// oxlint enables its default categories unless every one is switched off, and a
|
||||
// stray builtin diagnostic would be indistinguishable from the rule under test.
|
||||
const CATEGORIES_OFF = {
|
||||
correctness: 'off',
|
||||
suspicious: 'off',
|
||||
pedantic: 'off',
|
||||
perf: 'off',
|
||||
style: 'off',
|
||||
restriction: 'off',
|
||||
nursery: 'off',
|
||||
};
|
||||
|
||||
function normaliseCase(entry, index) {
|
||||
const testCase = typeof entry === 'string' ? { code: entry } : entry;
|
||||
const extension = testCase.filename
|
||||
? path.extname(testCase.filename).slice(1)
|
||||
: 'tsx';
|
||||
return {
|
||||
...testCase,
|
||||
index,
|
||||
basename: `case-${String(index).padStart(3, '0')}.${extension}`,
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticFilename(diagnostic) {
|
||||
const raw = diagnostic.filename ?? '';
|
||||
const asPath = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
|
||||
return path.basename(asPath);
|
||||
}
|
||||
|
||||
function toError(diagnostic) {
|
||||
const span = diagnostic.labels?.[0]?.span;
|
||||
return {
|
||||
message: diagnostic.message,
|
||||
line: span?.line,
|
||||
column: span?.column,
|
||||
};
|
||||
}
|
||||
|
||||
function runOxlint(dir, configPath, extraArgs = []) {
|
||||
const args = ['--config', configPath, '--format', 'json', ...extraArgs, '.'];
|
||||
try {
|
||||
return execFileSync(OXLINT_BIN, args, {
|
||||
cwd: dir,
|
||||
encoding: 'utf8',
|
||||
// A rule that reports on every case can produce a lot of output.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
// oxlint exits non-zero whenever it reports a diagnostic, which is the
|
||||
// expected outcome for every `invalid` case.
|
||||
if (typeof error.stdout === 'string' && error.stdout.trim() !== '') {
|
||||
return error.stdout;
|
||||
}
|
||||
throw new Error(`oxlint failed to run:\n${error.stderr || error.message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function writeSuite(dir, cases, { pluginPath, ruleId }) {
|
||||
for (const testCase of cases) {
|
||||
const target = path.join(dir, testCase.basename);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, testCase.code);
|
||||
}
|
||||
|
||||
const configPath = path.join(dir, '.oxlintrc.json');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
jsPlugins: [pluginPath],
|
||||
categories: CATEGORIES_OFF,
|
||||
rules: { [ruleId]: 'error' },
|
||||
}),
|
||||
);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lints every case in one pass, and applies suggestions in a second pass over an
|
||||
* untouched copy when any case declares `output`.
|
||||
*
|
||||
* @returns {{errors: Map<string, object[]>, outputs: Map<string, string>}}
|
||||
*/
|
||||
function lintCases(cases, options) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'oxlint-rule-tester-'));
|
||||
try {
|
||||
const lintDir = path.join(root, 'lint');
|
||||
mkdirSync(lintDir);
|
||||
const report = JSON.parse(
|
||||
runOxlint(lintDir, writeSuite(lintDir, cases, options)),
|
||||
);
|
||||
|
||||
const errors = new Map(cases.map((testCase) => [testCase.basename, []]));
|
||||
for (const diagnostic of report.diagnostics ?? []) {
|
||||
const bucket = errors.get(diagnosticFilename(diagnostic));
|
||||
// oxlint reports config-level problems without a filename; surfacing
|
||||
// them as a suite failure beats silently testing nothing.
|
||||
if (!bucket) {
|
||||
throw new Error(`Unexpected diagnostic: ${diagnostic.message}`);
|
||||
}
|
||||
bucket.push(toError(diagnostic));
|
||||
}
|
||||
|
||||
const outputs = new Map();
|
||||
if (cases.some((testCase) => testCase.output !== undefined)) {
|
||||
const fixDir = path.join(root, 'fix');
|
||||
mkdirSync(fixDir);
|
||||
runOxlint(fixDir, writeSuite(fixDir, cases, options), ['--fix-suggestions']);
|
||||
for (const testCase of cases) {
|
||||
outputs.set(
|
||||
testCase.basename,
|
||||
readFileSync(path.join(fixDir, testCase.basename), 'utf8'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, outputs };
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function assertMessage(actual, expected, label) {
|
||||
if (expected instanceof RegExp) {
|
||||
assert.match(actual, expected, label);
|
||||
} else {
|
||||
assert.ok(
|
||||
actual.includes(expected),
|
||||
`${label}\n expected message to contain: ${expected}\n actual: ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertErrors(actual, expected, code) {
|
||||
const context = `\n--- code ---\n${code}\n--- reported ---\n${JSON.stringify(actual, null, 2)}`;
|
||||
|
||||
if (typeof expected === 'number') {
|
||||
assert.equal(actual.length, expected, `error count${context}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(actual.length, expected.length, `error count${context}`);
|
||||
expected.forEach((want, i) => {
|
||||
const got = actual[i];
|
||||
if (want.message !== undefined) {
|
||||
assertMessage(got.message, want.message, `error[${i}] message${context}`);
|
||||
}
|
||||
if (want.line !== undefined) {
|
||||
assert.equal(got.line, want.line, `error[${i}] line${context}`);
|
||||
}
|
||||
if (want.column !== undefined) {
|
||||
assert.equal(got.column, want.column, `error[${i}] column${context}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a suite for one rule.
|
||||
*
|
||||
* A case carrying `todo` asserts the behaviour the rule *should* have. It still
|
||||
* runs, but a failure is reported as a todo instead of failing the suite, so a
|
||||
* known bug can be pinned as an executable spec. Delete the flag once the rule
|
||||
* is fixed and the case starts guarding the fix.
|
||||
*
|
||||
* An invalid case carrying `output` also asserts the source after
|
||||
* `--fix-suggestions` has been applied.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.rule - rule name as exported by the plugin
|
||||
* @param {string} [options.plugin] - path to the plugin, relative to `frontend/`
|
||||
* @param {Array<string | {code: string, name?: string, filename?: string, todo?: string}>} options.valid
|
||||
* @param {Array<{code: string, name?: string, filename?: string, todo?: string, output?: string, errors: number | Array<{message?: string | RegExp, line?: number, column?: number}>}>} options.invalid
|
||||
*/
|
||||
export async function ruleTester({
|
||||
rule,
|
||||
plugin = 'plugins/signoz.mjs',
|
||||
valid = [],
|
||||
invalid = [],
|
||||
}) {
|
||||
const pluginPath = path.join(FRONTEND_DIR, plugin);
|
||||
const { default: pluginModule } = await import(pathToFileURL(pluginPath));
|
||||
|
||||
assert.ok(
|
||||
pluginModule.rules?.[rule],
|
||||
`plugin ${plugin} does not export a rule named "${rule}"`,
|
||||
);
|
||||
|
||||
const ruleId = `${pluginModule.meta.name}/${rule}`;
|
||||
const validCases = valid.map(normaliseCase);
|
||||
const invalidCases = invalid.map((entry, i) =>
|
||||
normaliseCase(entry, valid.length + i),
|
||||
);
|
||||
const { errors, outputs } = lintCases([...validCases, ...invalidCases], {
|
||||
pluginPath,
|
||||
ruleId,
|
||||
});
|
||||
|
||||
const declare = (t, testCase, expected) => {
|
||||
const label = testCase.name ?? testCase.code.trim().split('\n')[0];
|
||||
return t.test(label, { todo: testCase.todo }, () => {
|
||||
assertErrors(errors.get(testCase.basename), expected, testCase.code);
|
||||
if (testCase.output !== undefined) {
|
||||
assert.equal(
|
||||
outputs.get(testCase.basename),
|
||||
testCase.output,
|
||||
`suggestion output\n--- code ---\n${testCase.code}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
test(ruleId, async (t) => {
|
||||
await t.test('valid', async (t) => {
|
||||
for (const testCase of validCases) {
|
||||
await declare(t, testCase, 0);
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('invalid', async (t) => {
|
||||
for (const testCase of invalidCases) {
|
||||
await declare(t, testCase, testCase.errors);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Rule: no-conditional-text-nodes-with-siblings
|
||||
*
|
||||
* Conditionally rendered text nodes with siblings should be wrapped in an
|
||||
* element (for example a `<span>`), otherwise Google Translate causes a browser
|
||||
* error. Translate replaces the text node with a `<font>` wrapper, React still
|
||||
* holds a reference to the original node, and the next render throws on
|
||||
* `removeChild`.
|
||||
*
|
||||
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
|
||||
* (v1.0.4). The upstream rule resolves branch types through
|
||||
* `@typescript-eslint/utils`; oxlint's JS plugin runtime exposes no type
|
||||
* information, so those paths are dropped and call expressions are matched
|
||||
* against the allowlist below instead.
|
||||
*/
|
||||
|
||||
// Calls known to render as text. Without types this is the only way to
|
||||
// recognise a string-returning call; widen it to catch more helpers.
|
||||
const TEXT_RETURNING_CALLEES = new Set(['formatMessage', 't']);
|
||||
const STRINGIFY_METHODS = new Set(['toString', 'toLocaleString']);
|
||||
|
||||
function calleeName(node) {
|
||||
return node.type === 'Identifier' ? node.name : null;
|
||||
}
|
||||
|
||||
function isTextReturningCall(node) {
|
||||
const { callee } = node;
|
||||
|
||||
if (TEXT_RETURNING_CALLEES.has(calleeName(callee))) {
|
||||
return node.arguments.length > 0;
|
||||
}
|
||||
|
||||
if (callee.type === 'MemberExpression' && !callee.computed) {
|
||||
return STRINGIFY_METHODS.has(calleeName(callee.property));
|
||||
}
|
||||
|
||||
return STRINGIFY_METHODS.has(calleeName(callee));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the node renders no visible text. An empty or whitespace-only value
|
||||
* produces no DOM text node, so Google Translate has nothing to wrap and React
|
||||
* nothing to lose.
|
||||
*/
|
||||
function isBlankText(node) {
|
||||
if (node.type === 'Literal' || node.type === 'JSXText') {
|
||||
return typeof node.value === 'string' && node.value.trim() === '';
|
||||
}
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
return (
|
||||
node.expressions.length === 0 &&
|
||||
node.quasis.every((quasi) => (quasi.value.cooked ?? '').trim() === '')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isConditionallyRendered(node) {
|
||||
const parent = node.parent;
|
||||
return (
|
||||
parent?.type === 'ConditionalExpression' ||
|
||||
parent?.type === 'LogicalExpression'
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderedConditional(node) {
|
||||
return (
|
||||
node.type === 'JSXExpressionContainer' &&
|
||||
(node.expression?.type === 'ConditionalExpression' ||
|
||||
node.expression?.type === 'LogicalExpression')
|
||||
);
|
||||
}
|
||||
|
||||
/** Children that produce output, i.e. everything but formatting whitespace. */
|
||||
function renderedChildren(node) {
|
||||
const children = node?.children;
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
return children.filter((child) => !isBlankText(child));
|
||||
}
|
||||
|
||||
/** True when `node` is a JSX child rendered alongside at least one other child. */
|
||||
function hasSiblings(node) {
|
||||
if (!(node?.parent?.children?.length > 1)) {
|
||||
return false;
|
||||
}
|
||||
return renderedChildren(node.parent).some((child) => child !== node);
|
||||
}
|
||||
|
||||
function isPrecededByConditional(node) {
|
||||
const children = renderedChildren(node?.parent);
|
||||
if (!children) {
|
||||
return false;
|
||||
}
|
||||
return children.some(
|
||||
(child) => child.start < node.start && isRenderedConditional(child),
|
||||
);
|
||||
}
|
||||
|
||||
/** Walk out of nested conditionals so nested branches report against the outer container. */
|
||||
function getOutermostConditional(node) {
|
||||
let current = node;
|
||||
while (isConditionallyRendered(current)) {
|
||||
current = current.parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** True when `node` is a conditional branch rendered directly beside other JSX children. */
|
||||
function isProblematicConditional(node) {
|
||||
if (!isConditionallyRendered(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const container = getOutermostConditional(node);
|
||||
return (
|
||||
container.parent?.type === 'JSXExpressionContainer' &&
|
||||
container.parent.parent?.type === 'JSXElement' &&
|
||||
hasSiblings(container.parent)
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `node` renders after a sibling conditional, i.e. the DOM order Translate breaks. */
|
||||
function followsConditionalSibling(node) {
|
||||
return (
|
||||
node.parent?.parent?.type === 'JSXElement' &&
|
||||
hasSiblings(node.parent) &&
|
||||
isPrecededByConditional(node.parent)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `A && B` and the test of a ternary are conditions, not rendered output.
|
||||
*/
|
||||
function isCondition(node) {
|
||||
let current = node;
|
||||
while (current.parent?.type === 'LogicalExpression') {
|
||||
if (current.parent.left === current) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
if (current.parent?.type === 'ConditionalExpression') {
|
||||
return current.parent.test === current;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isConditionOperand(node) {
|
||||
if (node.parent?.type === 'BinaryExpression') {
|
||||
return isCondition(node.parent);
|
||||
}
|
||||
return isCondition(node);
|
||||
}
|
||||
|
||||
// A string may only be inlined as JSX text when it needs no escaping and no
|
||||
// whitespace of its own: JSX collapses leading and trailing whitespace, and
|
||||
// these characters would either close the element or start an entity.
|
||||
const NEEDS_BRACES = /['"{}<>&\r\n]/;
|
||||
|
||||
// `display: contents`, declared in src/styles.scss. React owns the element so
|
||||
// Translate's `<font>` swap is absorbed, while the box tree stays as it was and
|
||||
// a flex or grid parent still sees one contiguous text run.
|
||||
const OPEN = '<span className="translate-safe">';
|
||||
const CLOSE = '</span>';
|
||||
|
||||
/** Wraps the reported expression so React owns an element Translate cannot replace. */
|
||||
function wrapExpression(fixer, sourceCode, node) {
|
||||
// A call reported on its own already sits in a container. Replacing the
|
||||
// container yields `<span …>{expr}</span>` rather than `{<span …>{expr}</span>}`.
|
||||
const target =
|
||||
node.parent?.type === 'JSXExpressionContainer' ? node.parent : node;
|
||||
|
||||
if (
|
||||
node.type === 'Literal' &&
|
||||
typeof node.value === 'string' &&
|
||||
!NEEDS_BRACES.test(node.value) &&
|
||||
node.value.trim() === node.value
|
||||
) {
|
||||
return fixer.replaceText(target, `${OPEN}${node.value}${CLOSE}`);
|
||||
}
|
||||
|
||||
return fixer.replaceText(
|
||||
target,
|
||||
`${OPEN}{${sourceCode.getText(node)}}${CLOSE}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps static JSX text. Only the visible run is wrapped: the node also spans
|
||||
* the formatting whitespace around it, which has to stay outside the element.
|
||||
*/
|
||||
function wrapJsxText(fixer, node) {
|
||||
const raw = node.value;
|
||||
const leading = raw.length - raw.trimStart().length;
|
||||
const trailing = raw.length - raw.trimEnd().length;
|
||||
return fixer.replaceTextRange(
|
||||
[node.start + leading, node.end - trailing],
|
||||
`${OPEN}${raw.trim()}${CLOSE}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Conditionally rendered text nodes should be wrapped in an element (for example a `<span>`), otherwise Google Translate can cause a browser error.',
|
||||
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
|
||||
},
|
||||
schema: [],
|
||||
// Wrapping adds a DOM element, which can turn into a flex/grid item or
|
||||
// break `> *` and `:nth-child` selectors, so it is offered as a suggestion
|
||||
// (`--fix-suggestions`) rather than applied by a bare `--fix`.
|
||||
hasSuggestions: true,
|
||||
messages: {
|
||||
'conditional-text-node':
|
||||
'Conditionally rendered text nodes with siblings, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">{value}</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`. This also applies to values returned from functions, so `getString()` becomes `<span className="translate-safe">{getString()}</span>`.',
|
||||
'text-node-preceded-by-conditional':
|
||||
'Text nodes which are preceded by a conditional expression, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">text</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`.',
|
||||
},
|
||||
},
|
||||
|
||||
createOnce(context) {
|
||||
const suggestWrap = (build) => [{ desc: 'Wrap in a <span>', fix: build }];
|
||||
|
||||
const wrap = (fixer, node) => wrapExpression(fixer, context.sourceCode, node);
|
||||
|
||||
const reportConditional = (node) => {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'conditional-text-node',
|
||||
suggest: suggestWrap((fixer) => wrap(fixer, node)),
|
||||
});
|
||||
};
|
||||
|
||||
const reportPreceded = (node, build) => {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'text-node-preceded-by-conditional',
|
||||
suggest: suggestWrap(build),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// String and numeric branches: `{flag ? 'yes' : 'no'}`
|
||||
Literal(node) {
|
||||
if (node.value === null || typeof node.value === 'boolean') {
|
||||
return;
|
||||
}
|
||||
if (isBlankText(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
|
||||
TemplateLiteral(node) {
|
||||
if (isBlankText(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
|
||||
// Static text rendered after a conditional: `{flag && <b/>}trailing`
|
||||
JSXText(node) {
|
||||
if (isBlankText(node)) {
|
||||
return;
|
||||
}
|
||||
if (hasSiblings(node) && isPrecededByConditional(node)) {
|
||||
reportPreceded(node, (fixer) => wrapJsxText(fixer, node));
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (isCondition(node) || !isTextReturningCall(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
if (followsConditionalSibling(node)) {
|
||||
reportPreceded(node, (fixer) => wrap(fixer, node));
|
||||
}
|
||||
},
|
||||
|
||||
// Values read off an object: `{flag ? user.name : 'anonymous'}`
|
||||
MemberExpression(node) {
|
||||
if (isConditionOperand(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
|
||||
// Optional chaining wraps the member expression: `{flag ? a?.b?.c : 'x'}`
|
||||
ChainExpression(node) {
|
||||
if (isConditionOperand(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
115
frontend/plugins/rules/no-return-text-nodes.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Rule: no-return-text-nodes
|
||||
*
|
||||
* React components should not return a bare text node. Google Translate keeps
|
||||
* displaying the stale translated text after a state change and nothing throws,
|
||||
* which makes the bug very hard to track down. Numbers count too: JSX renders
|
||||
* them as text.
|
||||
*
|
||||
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
|
||||
* (v1.0.4). Upstream walks the function body statement by statement; this
|
||||
* version visits `ReturnStatement` directly and walks up to the enclosing
|
||||
* function, which covers the same constructs without enumerating them.
|
||||
*/
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
]);
|
||||
|
||||
function isTextNode(node) {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
node.type === 'Literal' &&
|
||||
(typeof node.value === 'string' || typeof node.value === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
function getEnclosingFunction(node) {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (FUNCTION_TYPES.has(current.type)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isComponentName(name) {
|
||||
return (
|
||||
typeof name === 'string' && name !== '' && name[0] === name[0].toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
// The suggestion introduces JSX, which only parses in a JSX-enabled file.
|
||||
function allowsJsx(filename) {
|
||||
return filename.endsWith('.tsx') || filename.endsWith('.jsx');
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
|
||||
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
|
||||
},
|
||||
schema: [],
|
||||
// Wrapping changes what the component renders, so it is offered as a
|
||||
// suggestion (`--fix-suggestions`) rather than applied by a bare `--fix`.
|
||||
hasSuggestions: true,
|
||||
messages: {
|
||||
'return-value-is-text-node':
|
||||
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
|
||||
},
|
||||
},
|
||||
|
||||
createOnce(context) {
|
||||
const buildSuggestion = (node) => {
|
||||
if (!allowsJsx(context.filename)) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
{
|
||||
desc: 'Wrap in a <span>',
|
||||
fix: (fixer) =>
|
||||
fixer.replaceText(
|
||||
node.argument,
|
||||
`<span className="translate-safe">{${context.sourceCode.getText(node.argument)}}</span>`,
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return {
|
||||
ReturnStatement(node) {
|
||||
if (!isTextNode(node.argument)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only named function declarations are recognised as components, so a
|
||||
// text return from a nested helper or a class method is left alone.
|
||||
const fn = getEnclosingFunction(node);
|
||||
if (fn?.type !== 'FunctionDeclaration') {
|
||||
return;
|
||||
}
|
||||
if (!isComponentName(fn.id?.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'return-value-is-text-node',
|
||||
suggest: buildSuggestion(node),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,8 @@ import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
|
||||
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -27,5 +29,7 @@ export default {
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
|
||||
'no-return-text-nodes': noReturnTextNodes,
|
||||
},
|
||||
};
|
||||
|
||||
381
frontend/pnpm-lock.yaml
generated
@@ -13,7 +13,16 @@ overrides:
|
||||
'@babel/core@<=7.29.0': '>=7.29.6 <8'
|
||||
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
|
||||
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
|
||||
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
|
||||
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.1 <5'
|
||||
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
|
||||
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
|
||||
brace-expansion@<1.1.18: '>=1.1.18 <2'
|
||||
# via: jest > glob@10 > minimatch@9.0.9 (^2.0.1); also minimatch@5.1.9 (^2.0.1)
|
||||
# remove: blocked — jest 30 resolves glob@10 > minimatch@9 internally
|
||||
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <3'
|
||||
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
|
||||
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
|
||||
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
|
||||
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
|
||||
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
|
||||
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
|
||||
@@ -27,13 +36,26 @@ overrides:
|
||||
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
|
||||
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
|
||||
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
|
||||
# via: @commitlint/cli > @commitlint/config-validator > ajv@8 (fast-uri ^3.0.1)
|
||||
# remove: blocked — ajv@8 caps fast-uri at ^3, and only 3.1.5 carries the fix
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
# via: direct devDep sass@1.97.3 (immutable ^5.0.2)
|
||||
# remove: blocked — plain sass bumps stay within ^5, so the floor is what pulls 5.1.8
|
||||
immutable@<5.1.8: '>=5.1.8 <6'
|
||||
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
|
||||
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
|
||||
js-cookie@<=3.0.5: '>=3.0.7 <4'
|
||||
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
|
||||
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
|
||||
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
|
||||
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
|
||||
'js-yaml@>=4.0.0 <4.3.1': '>=4.3.1 <5'
|
||||
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
|
||||
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
|
||||
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
|
||||
less@<4.5.0: '>=4.5.0 <5'
|
||||
# via: direct dep postcss (nanoid ^3.3.17)
|
||||
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
|
||||
nanoid@<3.3.18: '>=3.3.18 <4'
|
||||
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
|
||||
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
|
||||
prismjs@<1.30.0: '>=1.30.0 <2'
|
||||
|
||||
236
frontend/src/api/generated/services/ai-observability/index.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetAIObservabilityFieldsKeys200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValues200,
|
||||
GetAIObservabilityFieldsValuesParams,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint returns the field keys the AI observability explorer can filter on, including the computed per-trace aggregates
|
||||
* @summary Get AI observability field keys
|
||||
*/
|
||||
export const getAIObservabilityFieldsKeys = (
|
||||
params?: GetAIObservabilityFieldsKeysParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetAIObservabilityFieldsKeys200>({
|
||||
url: `/api/v1/ai_observability/fields/keys`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAIObservabilityFieldsKeysQueryKey = (
|
||||
params?: GetAIObservabilityFieldsKeysParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/ai_observability/fields/keys`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetAIObservabilityFieldsKeysQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: GetAIObservabilityFieldsKeysParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetAIObservabilityFieldsKeysQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
|
||||
> = ({ signal }) => getAIObservabilityFieldsKeys(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAIObservabilityFieldsKeysQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
|
||||
>;
|
||||
export type GetAIObservabilityFieldsKeysQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get AI observability field keys
|
||||
*/
|
||||
|
||||
export function useGetAIObservabilityFieldsKeys<
|
||||
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: GetAIObservabilityFieldsKeysParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAIObservabilityFieldsKeysQueryOptions(
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get AI observability field keys
|
||||
*/
|
||||
export const invalidateGetAIObservabilityFieldsKeys = async (
|
||||
queryClient: QueryClient,
|
||||
params?: GetAIObservabilityFieldsKeysParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetAIObservabilityFieldsKeysQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns the values the AI observability explorer can filter a field key on
|
||||
* @summary Get AI observability field values
|
||||
*/
|
||||
export const getAIObservabilityFieldsValues = (
|
||||
params?: GetAIObservabilityFieldsValuesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetAIObservabilityFieldsValues200>({
|
||||
url: `/api/v1/ai_observability/fields/values`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAIObservabilityFieldsValuesQueryKey = (
|
||||
params?: GetAIObservabilityFieldsValuesParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/ai_observability/fields/values`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetAIObservabilityFieldsValuesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: GetAIObservabilityFieldsValuesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetAIObservabilityFieldsValuesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
|
||||
> = ({ signal }) => getAIObservabilityFieldsValues(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAIObservabilityFieldsValuesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
|
||||
>;
|
||||
export type GetAIObservabilityFieldsValuesQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get AI observability field values
|
||||
*/
|
||||
|
||||
export function useGetAIObservabilityFieldsValues<
|
||||
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: GetAIObservabilityFieldsValuesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAIObservabilityFieldsValuesQueryOptions(
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get AI observability field values
|
||||
*/
|
||||
export const invalidateGetAIObservabilityFieldsValues = async (
|
||||
queryClient: QueryClient,
|
||||
params?: GetAIObservabilityFieldsValuesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetAIObservabilityFieldsValuesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -38,14 +38,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
*/
|
||||
export const listAuthDomains = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListAuthDomains200>({
|
||||
url: `/api/v1/domains`,
|
||||
url: `/api/v2/auth_domains`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAuthDomainsQueryKey = () => {
|
||||
return [`/api/v1/domains`] as const;
|
||||
return [`/api/v2/auth_domains`] as const;
|
||||
};
|
||||
|
||||
export const getListAuthDomainsQueryOptions = <
|
||||
@@ -125,7 +125,7 @@ export const createAuthDomain = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAuthDomain201>({
|
||||
url: `/api/v1/domains`,
|
||||
url: `/api/v2/auth_domains`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableAuthDomainDTO,
|
||||
@@ -208,7 +208,7 @@ export const deleteAuthDomain = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/domains/${id}`,
|
||||
url: `/api/v2/auth_domains/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
@@ -287,7 +287,7 @@ export const getAuthDomain = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetAuthDomain200>({
|
||||
url: `/api/v1/domains/${id}`,
|
||||
url: `/api/v2/auth_domains/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
@@ -296,7 +296,7 @@ export const getAuthDomain = (
|
||||
export const getGetAuthDomainQueryKey = ({
|
||||
id,
|
||||
}: GetAuthDomainPathParameters) => {
|
||||
return [`/api/v1/domains/${id}`] as const;
|
||||
return [`/api/v2/auth_domains/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetAuthDomainQueryOptions = <
|
||||
@@ -389,7 +389,7 @@ export const updateAuthDomain = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/domains/${id}`,
|
||||
url: `/api/v2/auth_domains/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesUpdatableAuthDomainDTO,
|
||||
|
||||
@@ -22,10 +22,7 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
@@ -39,7 +36,6 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
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 createServiceAccountRoleDeprecated>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
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 deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
* @summary Gets my service account
|
||||
|
||||
@@ -1861,8 +1861,19 @@ export interface AuthtypesAttributeMappingDTO {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
|
||||
saml = 'saml',
|
||||
}
|
||||
export interface AuthtypesSamlConfigDTO {
|
||||
attributeMapping?: AuthtypesAttributeMappingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
certificate: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
entityId: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -1870,17 +1881,21 @@ export interface AuthtypesSamlConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
samlCert?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
samlEntity?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
samlIdp?: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesAuthDomainConfigSAMLDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum saml
|
||||
*/
|
||||
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
|
||||
spec: AuthtypesSamlConfigDTO;
|
||||
}
|
||||
|
||||
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
|
||||
google = 'google',
|
||||
}
|
||||
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -1893,11 +1908,12 @@ export interface AuthtypesGoogleConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientId?: string;
|
||||
clientId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
*/
|
||||
clientSecret?: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
@@ -1916,24 +1932,34 @@ export interface AuthtypesGoogleConfigDTO {
|
||||
insecureSkipEmailVerified?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
redirectURI?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
*/
|
||||
serviceAccountJson?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesAuthDomainConfigGoogleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum google
|
||||
*/
|
||||
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
|
||||
spec: AuthtypesGoogleConfigDTO;
|
||||
}
|
||||
|
||||
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
|
||||
oidc = 'oidc',
|
||||
}
|
||||
export interface AuthtypesOIDCConfigDTO {
|
||||
claimMapping?: AuthtypesAttributeMappingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientId?: string;
|
||||
clientId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
*/
|
||||
clientSecret?: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -1945,79 +1971,33 @@ export interface AuthtypesOIDCConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issuer?: string;
|
||||
issuer: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issuerAlias?: string;
|
||||
}
|
||||
|
||||
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AuthtypesRoleMappingDTOGroupMappings =
|
||||
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
|
||||
|
||||
export interface AuthtypesRoleMappingDTO {
|
||||
export interface AuthtypesAuthDomainConfigOIDCDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum oidc
|
||||
*/
|
||||
defaultRole?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
useRoleAttribute?: boolean;
|
||||
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
|
||||
spec: AuthtypesOIDCConfigDTO;
|
||||
}
|
||||
|
||||
export type AuthtypesAuthDomainConfigDTO =
|
||||
| AuthtypesAuthDomainConfigSAMLDTO
|
||||
| AuthtypesAuthDomainConfigGoogleDTO
|
||||
| AuthtypesAuthDomainConfigOIDCDTO;
|
||||
|
||||
export enum AuthtypesAuthNProviderDTO {
|
||||
google_auth = 'google_auth',
|
||||
google = 'google',
|
||||
saml = 'saml',
|
||||
email_password = 'email_password',
|
||||
oidc = 'oidc',
|
||||
}
|
||||
export type AuthtypesAuthDomainConfigDTO =
|
||||
| (AuthtypesSamlConfigDTO & {
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
})
|
||||
| (AuthtypesGoogleConfigDTO & {
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
})
|
||||
| (AuthtypesOIDCConfigDTO & {
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
});
|
||||
|
||||
export interface AuthtypesAuthNProviderInfoDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
@@ -2055,6 +2035,31 @@ export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AuthtypesRoleMappingDTOGroupMappings =
|
||||
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
|
||||
|
||||
export interface AuthtypesRoleMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
defaultRole?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
useRoleAttribute?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2063,6 +2068,10 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2075,6 +2084,7 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2271,11 +2281,16 @@ export interface AuthtypesOrgSessionContextDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
config: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
name: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableEmailPasswordSessionDTO {
|
||||
@@ -2408,7 +2423,12 @@ export interface AuthtypesTransactionDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdatableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
config: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdatableRoleDTO {
|
||||
@@ -3470,6 +3490,7 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
span = 'span',
|
||||
trace = 'trace',
|
||||
resource = 'resource',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
@@ -9071,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9934,47 +9948,6 @@ export interface TypesChangePasswordRequestDTO {
|
||||
oldPassword?: string;
|
||||
}
|
||||
|
||||
export interface TypesDeprecatedUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isRoot?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TypesIdentifiableDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9982,47 +9955,6 @@ export interface TypesIdentifiableDTO {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TypesInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
inviteLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TypesOrganizationDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10072,25 +10004,6 @@ export interface TypesPostableForgotPasswordDTO {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frontendBaseUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableResetPasswordDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10102,13 +10015,6 @@ export interface TypesPostableResetPasswordDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableVerifyResetPasswordTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10263,6 +10169,93 @@ export interface ZeustypesPostableProfileDTO {
|
||||
where_did_you_discover_signoz: string;
|
||||
}
|
||||
|
||||
export type GetAIObservabilityFieldsKeysParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
fieldContext?: TelemetrytypesFieldContextDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
startUnixMilli?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
endUnixMilli?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type GetAIObservabilityFieldsKeys200 = {
|
||||
data: TelemetrytypesGettableFieldKeysDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetAIObservabilityFieldsValuesParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
fieldContext?: TelemetrytypesFieldContextDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
startUnixMilli?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
endUnixMilli?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type GetAIObservabilityFieldsValues200 = {
|
||||
data: TelemetrytypesGettableFieldValuesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetAlerts200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10525,42 +10518,6 @@ export type CreatePublicDashboard201 = {
|
||||
export type UpdatePublicDashboardPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListAuthDomains200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesGettableAuthDomainDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAuthDomain201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomain200 = {
|
||||
data: AuthtypesGettableAuthDomainDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListDowntimeSchedulesParams = {
|
||||
/**
|
||||
* @type boolean,null
|
||||
@@ -10751,17 +10708,6 @@ export type GetFieldsValues200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetResetPasswordTokenDeprecatedPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetResetPasswordTokenDeprecated200 = {
|
||||
data: TypesResetPasswordTokenDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetGlobalConfig200 = {
|
||||
data: GlobaltypesConfigDTO;
|
||||
/**
|
||||
@@ -10770,14 +10716,6 @@ export type GetGlobalConfig200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateInvite201 = {
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListLLMPricingRulesParams = {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -11075,21 +11013,6 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
export type GetMyServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountWithRolesDTO;
|
||||
/**
|
||||
@@ -11190,25 +11113,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsersDeprecated200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesDeprecatedUserDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyUserDeprecated200 = {
|
||||
data: TypesDeprecatedUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -11234,6 +11138,42 @@ export type GetUserPreference200 = {
|
||||
export type UpdateUserPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListAuthDomains200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesGettableAuthDomainDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAuthDomain201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomain200 = {
|
||||
data: AuthtypesGettableAuthDomainDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListDashboardViews200 = {
|
||||
data: DashboardtypesListableDashboardViewDTO;
|
||||
/**
|
||||
@@ -12403,13 +12343,6 @@ export type GetRolesByUserID200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type SetRoleByUserIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type RemoveUserRoleByUserIDAndRoleIDPathParameters = {
|
||||
id: string;
|
||||
roleId: string;
|
||||
};
|
||||
export type GetMyUser200 = {
|
||||
data: AuthtypesUserWithRolesDTO;
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
AuthtypesPostableUserRoleDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
@@ -28,10 +27,7 @@ import type {
|
||||
DeleteUserPathParameters,
|
||||
DeleteUserRolePathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
GetResetPasswordToken200,
|
||||
GetResetPasswordTokenDeprecated200,
|
||||
GetResetPasswordTokenDeprecatedPathParameters,
|
||||
GetResetPasswordTokenPathParameters,
|
||||
GetRolesByUserID200,
|
||||
GetRolesByUserIDPathParameters,
|
||||
@@ -42,15 +38,10 @@ import type {
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListUsers200,
|
||||
ListUsersDeprecated200,
|
||||
RemoveUserRoleByUserIDAndRoleIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SetRoleByUserIDPathParameters,
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
TypesPostableRoleDTO,
|
||||
TypesPostableVerifyResetPasswordTokenDTO,
|
||||
TypesUpdatableUserDTO,
|
||||
UpdateUserPathParameters,
|
||||
@@ -60,379 +51,12 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint returns the reset password token by id
|
||||
* @deprecated
|
||||
* @summary Get reset password token
|
||||
*/
|
||||
export const getResetPasswordTokenDeprecated = (
|
||||
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetResetPasswordTokenDeprecated200>({
|
||||
url: `/api/v1/getResetPasswordToken/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetResetPasswordTokenDeprecatedQueryKey = ({
|
||||
id,
|
||||
}: GetResetPasswordTokenDeprecatedPathParameters) => {
|
||||
return [`/api/v1/getResetPasswordToken/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetResetPasswordTokenDeprecatedQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetResetPasswordTokenDeprecatedQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>
|
||||
> = ({ signal }) => getResetPasswordTokenDeprecated({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetResetPasswordTokenDeprecatedQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>
|
||||
>;
|
||||
export type GetResetPasswordTokenDeprecatedQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get reset password token
|
||||
*/
|
||||
|
||||
export function useGetResetPasswordTokenDeprecated<
|
||||
TData = Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getResetPasswordTokenDeprecated>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetResetPasswordTokenDeprecatedQueryOptions(
|
||||
{ id },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get reset password token
|
||||
*/
|
||||
export const invalidateGetResetPasswordTokenDeprecated = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetResetPasswordTokenDeprecatedPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetResetPasswordTokenDeprecatedQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const createInvite = (
|
||||
typesPostableInviteDTO?: BodyType<TypesPostableInviteDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateInvite201>({
|
||||
url: `/api/v1/invite`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableInviteDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateInviteMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createInvite'];
|
||||
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 createInvite>>,
|
||||
{ data?: BodyType<TypesPostableInviteDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createInvite(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createInvite>>
|
||||
>;
|
||||
export type CreateInviteMutationBody =
|
||||
| BodyType<TypesPostableInviteDTO>
|
||||
| undefined;
|
||||
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const useCreateInvite = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createInvite>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateInviteMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint resets the password by token
|
||||
* @deprecated
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const resetPasswordDeprecated = (
|
||||
typesPostableResetPasswordDTO?: BodyType<TypesPostableResetPasswordDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/resetPassword`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableResetPasswordDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getResetPasswordDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['resetPasswordDeprecated'];
|
||||
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 resetPasswordDeprecated>>,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return resetPasswordDeprecated(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ResetPasswordDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>
|
||||
>;
|
||||
export type ResetPasswordDeprecatedMutationBody =
|
||||
| BodyType<TypesPostableResetPasswordDTO>
|
||||
| undefined;
|
||||
export type ResetPasswordDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const useResetPasswordDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getResetPasswordDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all users
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const listUsersDeprecated = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListUsersDeprecated200>({
|
||||
url: `/api/v1/user`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListUsersDeprecatedQueryKey = () => {
|
||||
return [`/api/v1/user`] as const;
|
||||
};
|
||||
|
||||
export const getListUsersDeprecatedQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listUsersDeprecated>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUsersDeprecated>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListUsersDeprecatedQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listUsersDeprecated>>
|
||||
> = ({ signal }) => listUsersDeprecated(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUsersDeprecated>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListUsersDeprecatedQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listUsersDeprecated>>
|
||||
>;
|
||||
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
|
||||
export function useListUsersDeprecated<
|
||||
TData = Awaited<ReturnType<typeof listUsersDeprecated>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUsersDeprecated>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListUsersDeprecatedQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const invalidateListUsersDeprecated = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListUsersDeprecatedQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns the user I belong to
|
||||
* This endpoint is deprecated and always fails. Use GET /api/v2/users/me instead.
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const getMyUserDeprecated = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMyUserDeprecated200>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/user/me`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
@@ -1834,189 +1458,6 @@ export const invalidateGetRolesByUserID = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns the role to the user roles by user id
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const setRoleByUserID = (
|
||||
{ id }: SetRoleByUserIDPathParameters,
|
||||
typesPostableRoleDTO?: BodyType<TypesPostableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/users/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSetRoleByUserIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof setRoleByUserID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: SetRoleByUserIDPathParameters;
|
||||
data?: BodyType<TypesPostableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof setRoleByUserID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: SetRoleByUserIDPathParameters;
|
||||
data?: BodyType<TypesPostableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['setRoleByUserID'];
|
||||
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 setRoleByUserID>>,
|
||||
{
|
||||
pathParams: SetRoleByUserIDPathParameters;
|
||||
data?: BodyType<TypesPostableRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return setRoleByUserID(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type SetRoleByUserIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof setRoleByUserID>>
|
||||
>;
|
||||
export type SetRoleByUserIDMutationBody =
|
||||
| BodyType<TypesPostableRoleDTO>
|
||||
| undefined;
|
||||
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const useSetRoleByUserID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof setRoleByUserID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: SetRoleByUserIDPathParameters;
|
||||
data?: BodyType<TypesPostableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof setRoleByUserID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: SetRoleByUserIDPathParameters;
|
||||
data?: BodyType<TypesPostableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getSetRoleByUserIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint removes a role from the user by user id and role id
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const removeUserRoleByUserIDAndRoleID = (
|
||||
{ id, roleId }: RemoveUserRoleByUserIDAndRoleIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/users/${id}/roles/${roleId}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getRemoveUserRoleByUserIDAndRoleIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
|
||||
TError,
|
||||
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
|
||||
TError,
|
||||
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['removeUserRoleByUserIDAndRoleID'];
|
||||
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 removeUserRoleByUserIDAndRoleID>>,
|
||||
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return removeUserRoleByUserIDAndRoleID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RemoveUserRoleByUserIDAndRoleIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>
|
||||
>;
|
||||
|
||||
export type RemoveUserRoleByUserIDAndRoleIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const useRemoveUserRoleByUserIDAndRoleID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
|
||||
TError,
|
||||
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof removeUserRoleByUserIDAndRoleID>>,
|
||||
TError,
|
||||
{ pathParams: RemoveUserRoleByUserIDAndRoleIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRemoveUserRoleByUserIDAndRoleIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user I belong to
|
||||
* @summary Get my user v2
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface HostListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
} | null;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesValue {
|
||||
timestamp: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TimeSeries {
|
||||
labels: Record<string, string>;
|
||||
labelsArray: Array<Record<string, string>>;
|
||||
values: TimeSeriesValue[];
|
||||
}
|
||||
|
||||
export interface HostData {
|
||||
hostName: string;
|
||||
active: boolean;
|
||||
os: string;
|
||||
/** Present when the list API returns grouped rows or extra resource attributes. */
|
||||
meta?: Record<string, string>;
|
||||
cpu: number;
|
||||
cpuTimeSeries: TimeSeries;
|
||||
memory: number;
|
||||
memoryTimeSeries: TimeSeries;
|
||||
wait: number;
|
||||
waitTimeSeries: TimeSeries;
|
||||
load15: number;
|
||||
load15TimeSeries: TimeSeries;
|
||||
}
|
||||
|
||||
export interface HostListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
type: string;
|
||||
records: HostData[];
|
||||
groups: null;
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
isSendingK8SAgentMetrics: boolean;
|
||||
endTimeBeforeRetention: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const getHostLists = async (
|
||||
props: HostListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponse<HostListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post('/hosts/list', props, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
params: props,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
3
frontend/src/assets/Logos/azure.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
1
frontend/src/assets/Logos/cloudnative-pg.svg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
5
frontend/src/assets/Logos/dify.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Dify</title>
|
||||
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
|
||||
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
3
frontend/src/assets/Logos/github-copilot.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/Logos/keda.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
24
frontend/src/assets/Logos/kong.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
|
||||
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
|
||||
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
|
||||
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
frontend/src/assets/Logos/neon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -8,12 +8,19 @@ import {
|
||||
|
||||
import ChangelogRenderer from '../components/ChangelogRenderer';
|
||||
|
||||
// Mock react-markdown to just render children as plain text
|
||||
// Mock react-markdown to render children as plain text and a sample
|
||||
// anchor through the `components.a` override
|
||||
jest.mock(
|
||||
'react-markdown',
|
||||
() =>
|
||||
function ReactMarkdown({ children }: any) {
|
||||
return <div>{children}</div>;
|
||||
function ReactMarkdown({ children, components }: any) {
|
||||
const Anchor = components?.a;
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
{Anchor && <Anchor href="https://signoz.io/docs">docs</Anchor>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -62,4 +69,14 @@ describe('ChangelogRenderer', () => {
|
||||
expect(screen.getByAltText('Media')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders markdown links that open in a new tab', () => {
|
||||
render(<ChangelogRenderer changelog={mockChangelog} />);
|
||||
const links = screen.getAllByRole('link', { name: 'docs' });
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
links.forEach((link) => {
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,19 @@ interface Props {
|
||||
changelog: ChangelogSchema;
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Link({ href, children }: LinkProps): JSX.Element {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMedia(media: Media): JSX.Element | null {
|
||||
if (SupportedImageTypes.includes(media.ext)) {
|
||||
return (
|
||||
@@ -62,7 +75,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
|
||||
<div key={feature.id}>
|
||||
<div className="changelog-renderer-section-title">{feature.title}</div>
|
||||
{feature.media && renderMedia(feature.media)}
|
||||
<ReactMarkdown>{feature.description}</ReactMarkdown>
|
||||
<ReactMarkdown components={{ a: Link }}>
|
||||
{feature.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -71,7 +86,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
|
||||
<div className="changelog-renderer-bug-fixes">
|
||||
<div className="changelog-renderer-section-title">Bug Fixes</div>
|
||||
{changelog.bug_fixes && (
|
||||
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
|
||||
<ReactMarkdown components={{ a: Link }}>
|
||||
{changelog.bug_fixes}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -79,7 +96,9 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
|
||||
<div className="changelog-renderer-maintenance">
|
||||
<div className="changelog-renderer-section-title">Maintenance</div>
|
||||
{changelog.maintenance && (
|
||||
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
|
||||
<ReactMarkdown components={{ a: Link }}>
|
||||
{changelog.maintenance}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.client-side-qb-search {
|
||||
.ant-select-selection-search {
|
||||
width: max-content !important;
|
||||
}
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
|
||||
import {
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
QUERY_BUILDER_SEARCH_VALUES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
|
||||
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
|
||||
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
|
||||
import {
|
||||
checkCommaInValue,
|
||||
getOperatorFromValue,
|
||||
getOperatorValue,
|
||||
getTagToken,
|
||||
isInNInOperator,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import {
|
||||
DropdownState,
|
||||
ITag,
|
||||
Option,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
|
||||
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
|
||||
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
|
||||
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './ClientSideQBSearch.styles.scss';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
export interface AttributeKey {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface AttributeValuesMap {
|
||||
[key: string]: AttributeValue;
|
||||
}
|
||||
|
||||
interface ClientSideQBSearchProps {
|
||||
filters: TagFilter;
|
||||
onChange: (value: TagFilter) => void;
|
||||
whereClauseConfig?: WhereClauseConfig;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
attributeValuesMap?: AttributeValuesMap;
|
||||
attributeKeys: AttributeKey[];
|
||||
}
|
||||
|
||||
interface AttributeValue {
|
||||
stringAttributeValues: string[] | [];
|
||||
numberAttributeValues: number[] | [];
|
||||
boolAttributeValues: boolean[] | [];
|
||||
}
|
||||
|
||||
function ClientSideQBSearch(
|
||||
props: ClientSideQBSearchProps,
|
||||
): React.ReactElement {
|
||||
const {
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
attributeValuesMap,
|
||||
attributeKeys,
|
||||
filters,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
|
||||
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
|
||||
|
||||
// this will maintain the current state of in process filter item
|
||||
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
|
||||
|
||||
const [currentState, setCurrentState] = useState<DropdownState>(
|
||||
DropdownState.ATTRIBUTE_KEY,
|
||||
);
|
||||
|
||||
// to maintain the current running state until the tokenization happens for the tag
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
|
||||
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
|
||||
|
||||
const attributeValues = useMemo(() => {
|
||||
if (currentFilterItem?.key?.key) {
|
||||
return attributeValuesMap?.[currentFilterItem.key.key];
|
||||
}
|
||||
return {
|
||||
stringAttributeValues: [],
|
||||
numberAttributeValues: [],
|
||||
boolAttributeValues: [],
|
||||
};
|
||||
}, [attributeValuesMap, currentFilterItem?.key?.key]);
|
||||
|
||||
const handleDropdownSelect = useCallback(
|
||||
(value: string) => {
|
||||
let parsedValue: BaseAutocompleteData | string;
|
||||
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
if (currentState === DropdownState.ATTRIBUTE_KEY) {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
...prev,
|
||||
key: parsedValue as BaseAutocompleteData,
|
||||
op: '',
|
||||
value: '',
|
||||
}));
|
||||
setCurrentState(DropdownState.OPERATOR);
|
||||
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
|
||||
} else if (currentState === DropdownState.OPERATOR) {
|
||||
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: value,
|
||||
value: '',
|
||||
} as ITag,
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: value as string,
|
||||
value: '',
|
||||
}));
|
||||
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
|
||||
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
|
||||
}
|
||||
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
|
||||
const operatorType =
|
||||
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
|
||||
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
|
||||
|
||||
if (isMulti) {
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
|
||||
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
|
||||
// not the best interaction but in sync with what we have today!
|
||||
if (tagValue.includes(String(value))) {
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
setCurrentFilterItem(undefined);
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: currentFilterItem?.op,
|
||||
value: tagValue,
|
||||
} as ITag,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// this is for adding subsequent comma seperated values
|
||||
const newSearch = [...tagValue];
|
||||
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
|
||||
const newSearchValue = newSearch.join(',');
|
||||
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
|
||||
} else {
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
setCurrentFilterItem(undefined);
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: currentFilterItem?.op,
|
||||
value,
|
||||
} as ITag,
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
}, []);
|
||||
|
||||
const onInputKeyDownHandler = useCallback(
|
||||
(event: KeyboardEvent<Element>): void => {
|
||||
if (event.key === 'Backspace' && !searchValue) {
|
||||
event.stopPropagation();
|
||||
setTags((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
[searchValue],
|
||||
);
|
||||
|
||||
const handleOnBlur = useCallback((): void => {
|
||||
if (searchValue) {
|
||||
const operatorType =
|
||||
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
|
||||
// if key is added and operator is not present then convert to body CONTAINS key
|
||||
if (
|
||||
currentFilterItem?.key &&
|
||||
isEmpty(currentFilterItem?.op) &&
|
||||
whereClauseConfig?.customKey === 'body' &&
|
||||
whereClauseConfig?.customOp === OPERATORS.CONTAINS
|
||||
) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: {
|
||||
key: 'body',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'body--string----true',
|
||||
},
|
||||
op: OPERATORS.CONTAINS,
|
||||
value: currentFilterItem?.key?.key,
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (
|
||||
currentFilterItem?.op === OPERATORS.EXISTS ||
|
||||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
|
||||
) {
|
||||
// is exists and not exists operator is present then convert directly to tag! no need of value here
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: currentFilterItem?.op,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (
|
||||
// if the current state is in sync with the kind of operator used then convert into a tag
|
||||
validationMapper[operatorType]?.(
|
||||
isArray(currentFilterItem?.value)
|
||||
? currentFilterItem?.value.length || 0
|
||||
: 1,
|
||||
)
|
||||
) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key as BaseAutocompleteData,
|
||||
op: currentFilterItem?.op as string,
|
||||
value: currentFilterItem?.value || '',
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentFilterItem?.key,
|
||||
currentFilterItem?.op,
|
||||
currentFilterItem?.value,
|
||||
searchValue,
|
||||
whereClauseConfig?.customKey,
|
||||
whereClauseConfig?.customOp,
|
||||
]);
|
||||
|
||||
// this useEffect takes care of tokenisation based on the search state
|
||||
useEffect(() => {
|
||||
// if there is no search value reset to the default state
|
||||
if (!searchValue) {
|
||||
setCurrentFilterItem(undefined);
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
}
|
||||
|
||||
// split the current search value based on delimiters
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
|
||||
|
||||
if (
|
||||
// Case 1 - if key is defined but the search text doesn't match with the set key,
|
||||
// can happen when user selects from dropdown and then deletes a few characters
|
||||
currentFilterItem?.key &&
|
||||
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
|
||||
) {
|
||||
setCurrentFilterItem(undefined);
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
|
||||
// Case 2 -> key is set and now typing for the operator
|
||||
if (
|
||||
tagOperator === OPERATORS.EXISTS ||
|
||||
tagOperator === OPERATORS.NOT_EXISTS
|
||||
) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: tagOperator,
|
||||
value: '',
|
||||
} as ITag,
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: tagOperator,
|
||||
value: '',
|
||||
}));
|
||||
|
||||
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
|
||||
}
|
||||
} else if (
|
||||
// Case 3 -> selected operator from dropdown and then erased a part of it
|
||||
!isEmpty(currentFilterItem?.op) &&
|
||||
tagOperator !== currentFilterItem?.op
|
||||
) {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: '',
|
||||
value: '',
|
||||
}));
|
||||
setCurrentState(DropdownState.OPERATOR);
|
||||
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
|
||||
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
|
||||
// dropdown click or blur event
|
||||
const currentValue = {
|
||||
key: currentFilterItem?.key as BaseAutocompleteData,
|
||||
op: currentFilterItem?.op as string,
|
||||
value: tagValue,
|
||||
};
|
||||
if (!isEqual(currentValue, currentFilterItem)) {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: prev?.op as string,
|
||||
value: tagValue,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentFilterItem,
|
||||
currentFilterItem?.key,
|
||||
currentFilterItem?.op,
|
||||
searchValue,
|
||||
currentState,
|
||||
]);
|
||||
|
||||
// the useEffect takes care of setting the dropdown values correctly on change of the current state
|
||||
useEffect(() => {
|
||||
if (currentState === DropdownState.ATTRIBUTE_KEY) {
|
||||
const filteredAttributeKeys = attributeKeys.filter((key) =>
|
||||
key.key.startsWith(searchValue),
|
||||
);
|
||||
setDropdownOptions(
|
||||
filteredAttributeKeys?.map(
|
||||
(key) =>
|
||||
({
|
||||
label: key.key,
|
||||
value: key,
|
||||
}) as Option,
|
||||
) || [],
|
||||
);
|
||||
}
|
||||
if (currentState === DropdownState.OPERATOR) {
|
||||
const keyOperator = searchValue.split(' ');
|
||||
const partialOperator = keyOperator?.[1];
|
||||
const strippedKey = keyOperator?.[0];
|
||||
|
||||
let operatorOptions;
|
||||
if (currentFilterItem?.key?.dataType) {
|
||||
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentFilterItem.key
|
||||
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
].map((operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}));
|
||||
|
||||
if (partialOperator) {
|
||||
operatorOptions = operatorOptions.filter((op) =>
|
||||
op.label.startsWith(partialOperator.toLocaleUpperCase()),
|
||||
);
|
||||
}
|
||||
setDropdownOptions(operatorOptions);
|
||||
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
|
||||
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}));
|
||||
setDropdownOptions(operatorOptions);
|
||||
} else {
|
||||
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
|
||||
(operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}),
|
||||
);
|
||||
|
||||
if (partialOperator) {
|
||||
operatorOptions = operatorOptions.filter((op) =>
|
||||
op.label.startsWith(partialOperator.toLocaleUpperCase()),
|
||||
);
|
||||
}
|
||||
setDropdownOptions(operatorOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
|
||||
const values: Array<string | number | boolean> = [];
|
||||
const { tagValue } = getTagToken(searchValue);
|
||||
if (isArray(tagValue)) {
|
||||
if (!isEmpty(tagValue[tagValue.length - 1])) {
|
||||
values.push(tagValue[tagValue.length - 1]);
|
||||
}
|
||||
} else if (!isEmpty(tagValue)) {
|
||||
values.push(tagValue);
|
||||
}
|
||||
|
||||
const currentAttributeValues =
|
||||
attributeValues?.stringAttributeValues ||
|
||||
attributeValues?.numberAttributeValues ||
|
||||
attributeValues?.boolAttributeValues ||
|
||||
[];
|
||||
|
||||
values.push(...currentAttributeValues);
|
||||
|
||||
if (attributeValuesMap) {
|
||||
setDropdownOptions(
|
||||
values.map(
|
||||
(val) =>
|
||||
({
|
||||
label: checkCommaInValue(String(val)),
|
||||
value: val,
|
||||
}) as Option,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// If attributeValuesMap is not provided, don't set dropdown options
|
||||
setDropdownOptions([]);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
attributeValues,
|
||||
currentFilterItem?.key?.dataType,
|
||||
currentState,
|
||||
attributeKeys,
|
||||
searchValue,
|
||||
attributeValuesMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const filterTags: IBuilderQuery['filters'] = {
|
||||
op: 'AND',
|
||||
items: [],
|
||||
};
|
||||
tags.forEach((tag) => {
|
||||
const computedTagValue =
|
||||
tag.value &&
|
||||
Array.isArray(tag.value) &&
|
||||
tag.value[tag.value.length - 1] === ''
|
||||
? tag.value?.slice(0, -1)
|
||||
: (tag.value ?? '');
|
||||
filterTags.items.push({
|
||||
id: tag.id || uuid().slice(0, 8),
|
||||
key: tag.key,
|
||||
op: getOperatorValue(tag.op),
|
||||
value: computedTagValue,
|
||||
});
|
||||
});
|
||||
|
||||
if (!isEqual(filters, filterTags)) {
|
||||
onChange(filterTags);
|
||||
setTags(
|
||||
filterTags.items.map((tag) => ({
|
||||
...tag,
|
||||
op: getOperatorFromValue(tag.op),
|
||||
})) as ITag[],
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
const queryTags = useMemo(
|
||||
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
|
||||
[tags],
|
||||
);
|
||||
|
||||
const onTagRender = ({
|
||||
value,
|
||||
closable,
|
||||
onClose,
|
||||
}: CustomTagProps): React.ReactElement => {
|
||||
const { tagOperator } = getTagToken(value);
|
||||
const isInNin = isInNInOperator(tagOperator);
|
||||
const chipValue = isInNin
|
||||
? value?.trim()?.replace(/,\s*$/, '')
|
||||
: value?.trim();
|
||||
|
||||
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
|
||||
const tagDetails = tags[indexInQueryTags];
|
||||
|
||||
const onCloseHandler = (): void => {
|
||||
onClose();
|
||||
setSearchValue('');
|
||||
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
|
||||
};
|
||||
|
||||
const tagEditHandler = (value: string): void => {
|
||||
setCurrentFilterItem(tagDetails);
|
||||
setSearchValue(value);
|
||||
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
|
||||
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
|
||||
};
|
||||
|
||||
const isDisabled = !!searchValue;
|
||||
|
||||
return (
|
||||
<span className="qb-search-bar-tokenised-tags">
|
||||
<Badge
|
||||
color="vanilla"
|
||||
className={tagDetails?.key?.type || ''}
|
||||
closable={!searchValue && closable}
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
onCloseHandler();
|
||||
}}
|
||||
>
|
||||
<Tooltip title={chipValue}>
|
||||
<TypographyText
|
||||
$isInNin={isInNin}
|
||||
$isEnabled={!!searchValue}
|
||||
$disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
if (!isDisabled) {
|
||||
tagEditHandler(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{chipValue}
|
||||
</TypographyText>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const suffixIconContent = useMemo(() => {
|
||||
if (suffixIcon) {
|
||||
return suffixIcon;
|
||||
}
|
||||
return isOpen ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
);
|
||||
}, [isDarkMode, isOpen, suffixIcon]);
|
||||
|
||||
return (
|
||||
<div className="query-builder-search-v2 ">
|
||||
<Select
|
||||
ref={selectRef}
|
||||
getPopupContainer={popupContainer}
|
||||
virtual={false}
|
||||
showSearch
|
||||
tagRender={onTagRender}
|
||||
transitionName=""
|
||||
choiceTransitionName=""
|
||||
filterOption={false}
|
||||
open={isOpen}
|
||||
suffixIcon={suffixIconContent}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
value={queryTags}
|
||||
searchValue={searchValue}
|
||||
className={className}
|
||||
rootClassName="query-builder-search client-side-qb-search"
|
||||
disabled={!attributeKeys.length}
|
||||
style={selectStyle}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleDropdownSelect}
|
||||
onInputKeyDown={onInputKeyDownHandler}
|
||||
notFoundContent={null}
|
||||
showAction={['focus']}
|
||||
onBlur={handleOnBlur}
|
||||
>
|
||||
{dropdownOptions.map((option) => {
|
||||
let val = option.value;
|
||||
try {
|
||||
if (isObject(option.value)) {
|
||||
val = JSON.stringify(option.value);
|
||||
} else {
|
||||
val = option.value;
|
||||
}
|
||||
} catch {
|
||||
val = option.value;
|
||||
}
|
||||
return (
|
||||
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
|
||||
<Suggestions
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
option={currentState}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ClientSideQBSearch.defaultProps = {
|
||||
placeholder: PLACEHOLDER,
|
||||
className: '',
|
||||
suffixIcon: null,
|
||||
whereClauseConfig: {},
|
||||
attributeValuesMap: {},
|
||||
};
|
||||
|
||||
export default ClientSideQBSearch;
|
||||
@@ -19,6 +19,7 @@ export type LogDetailProps = {
|
||||
onScrollToLog?: (logId: string) => void;
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
.ant-drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -79,6 +78,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
margin-top: 16px;
|
||||
|
||||
.log-body {
|
||||
font-family: 'SF Mono';
|
||||
@@ -123,6 +123,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.log-detail-drawer__section-divider {
|
||||
height: 8px;
|
||||
margin: 12px 0;
|
||||
background-image:
|
||||
radial-gradient(circle, var(--l3-border) 1px, transparent 1px),
|
||||
radial-gradient(circle, var(--l3-border) 1px, transparent 1px);
|
||||
background-size: 6px 2px;
|
||||
background-position:
|
||||
left top,
|
||||
left bottom;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.tabs-and-search {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Link,
|
||||
} from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { MouseEvent, MouseEventHandler } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
@@ -67,6 +68,11 @@ function LogDetailsHeader({
|
||||
},
|
||||
];
|
||||
|
||||
const rawTimestamp = log.date ?? log.timestamp;
|
||||
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
|
||||
? rawTimestamp
|
||||
: normalizeTimeToMs(rawTimestamp);
|
||||
|
||||
return (
|
||||
<div className={styles.header} data-log-detail-ignore="true">
|
||||
<div className={styles.leftSection}>
|
||||
@@ -76,7 +82,7 @@ function LogDetailsHeader({
|
||||
data-testid="log-details-header-timestamp"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
log.date ?? log.timestamp,
|
||||
displayTimestamp,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -11,10 +11,16 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// The flag to be removed later
|
||||
jest.mock('../constants', () => ({
|
||||
...jest.requireActual('../constants'),
|
||||
isLogDetailsV2: true,
|
||||
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
|
||||
// do). These drawer tests assert the header/highlights, not the Overview body.
|
||||
jest.mock('periscope/components/DataViewer', () => ({
|
||||
__esModule: true,
|
||||
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
|
||||
}));
|
||||
|
||||
// Force v2 for these tests regardless of route.
|
||||
jest.mock('../useIsLogDetailsV2', () => ({
|
||||
useIsLogDetailsV2: (): boolean => true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
@@ -66,6 +72,12 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the DataViewer in the Overview tab', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByTestId('overview-data-viewer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
|
||||
// Pin the timezone to UTC so the formatted output is deterministic across
|
||||
// machines/CI (Jest doesn't fix a TZ).
|
||||
@@ -79,6 +91,24 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a nanosecond-epoch timestamp in the header', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
|
||||
|
||||
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
|
||||
// Must scale to ms, not render a wildly wrong date.
|
||||
renderDrawer({
|
||||
log: {
|
||||
...mockLog,
|
||||
date: '1705311930000000000',
|
||||
timestamp: 1705311930000000000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
|
||||
'Jan 15, 2024 ⎯ 09:45:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('copies the log link from the ⋯ menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import getLocalStorage from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// Temp feature flag before actual roll-out
|
||||
export const isLogDetailsV2 =
|
||||
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: 'OVERVIEW',
|
||||
JSON: 'JSON',
|
||||
|
||||
@@ -51,11 +51,12 @@ import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -74,6 +75,7 @@ function LogDetailInner({
|
||||
onScrollToLog,
|
||||
handleOpenInExplorer,
|
||||
getContainer,
|
||||
onApplyLogFilter,
|
||||
}: LogDetailInnerProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
@@ -92,6 +94,8 @@ function LogDetailInner({
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
@@ -100,6 +104,7 @@ function LogDetailInner({
|
||||
// Don't close if clicking on drawer content, overlays, or portal elements
|
||||
if (
|
||||
target.closest('[data-log-detail-ignore="true"]') ||
|
||||
target.closest('.log-detail-drawer') ||
|
||||
target.closest('.cm-tooltip-autocomplete') ||
|
||||
target.closest('.drawer-popover') ||
|
||||
target.closest('.query-status-popover') ||
|
||||
@@ -402,6 +407,8 @@ function LogDetailInner({
|
||||
|
||||
{isLogDetailsV2 && <LogHighlights log={log} />}
|
||||
|
||||
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
@@ -418,15 +425,21 @@ function LogDetailInner({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.JSON,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Braces size={14} />
|
||||
JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
|
||||
// JSON tab is redundant.
|
||||
...(isLogDetailsV2
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: VIEW_TYPES.JSON,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Braces size={14} />
|
||||
JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
@@ -507,9 +520,10 @@ function LogDetailInner({
|
||||
selectedOptions={options}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && (
|
||||
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
|
||||
<JsonView data={LogJsonData} height="68vh" />
|
||||
)}
|
||||
|
||||
|
||||
11
frontend/src/components/LogDetail/useIsLogDetailsV2.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useIsLogDetailsV2(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
pathname === ROUTES.LOGS_EXPLORER ||
|
||||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
|
||||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ jest.mock('providers/Timezone', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
|
||||
}));
|
||||
|
||||
const field = (name: string, type = ''): IField => ({
|
||||
name,
|
||||
type,
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -26,6 +28,10 @@ export function useLogsTableColumns({
|
||||
fontSize,
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
return useMemo<TableColumnDef<ILog>[]>(() => {
|
||||
const stateIndicatorCol: TableColumnDef<ILog> = {
|
||||
@@ -88,7 +94,8 @@ export function useLogsTableColumns({
|
||||
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
|
||||
id: buildCompositeKey(f.name, f.type),
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||
accessorFn: (log): unknown =>
|
||||
getLogFieldValue(log, f.name, isBodyJsonEnabled),
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
@@ -115,5 +122,5 @@ export function useLogsTableColumns({
|
||||
.filter((c): c is TableColumnDef<ILog> => c !== null);
|
||||
|
||||
return [stateIndicatorCol, ...fieldCols];
|
||||
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
}, [fields, fontSize, formatTimezoneAdjustedTimestamp, isBodyJsonEnabled]);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
dedupeOptionsByLabel,
|
||||
getFieldContextPrefix,
|
||||
getRecentOptions,
|
||||
isSupportedFunction,
|
||||
renderRecentDeleteButton,
|
||||
} from './utils';
|
||||
|
||||
@@ -1275,11 +1276,13 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
if (queryContext.isInFunction) {
|
||||
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
|
||||
label: option,
|
||||
apply: `${option}()`,
|
||||
type: 'function',
|
||||
}));
|
||||
options = Object.values(QUERY_BUILDER_FUNCTIONS)
|
||||
.filter((option) => isSupportedFunction(option, dataSource))
|
||||
.map((option) => ({
|
||||
label: option,
|
||||
apply: `${option}()`,
|
||||
type: 'function',
|
||||
}));
|
||||
|
||||
// Add space after selection for functions
|
||||
const optionsWithSpace = addSpaceToOptions(options);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
getFieldContextPrefix,
|
||||
getUserExpressionFromCombined,
|
||||
isSupportedFunction,
|
||||
} from '../utils';
|
||||
|
||||
describe('entityLogsExpression', () => {
|
||||
@@ -118,3 +122,19 @@ describe('dedupeOptionsByLabel', () => {
|
||||
expect(dedupeOptionsByLabel([])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedFunction', () => {
|
||||
const { HASANY, SEARCH } = QUERY_BUILDER_FUNCTIONS;
|
||||
|
||||
it('allows the has family on every signal', () => {
|
||||
[DataSource.LOGS, DataSource.TRACES, DataSource.METRICS].forEach((signal) => {
|
||||
expect(isSupportedFunction(HASANY, signal)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows search on logs only', () => {
|
||||
expect(isSupportedFunction(SEARCH, DataSource.LOGS)).toBe(true);
|
||||
expect(isSupportedFunction(SEARCH, DataSource.TRACES)).toBe(false);
|
||||
expect(isSupportedFunction(SEARCH, DataSource.METRICS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
|
||||
import type { Completion } from '@codemirror/autocomplete';
|
||||
import type { EditorView } from '@uiw/react-codemirror';
|
||||
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
|
||||
import dayjs from 'dayjs';
|
||||
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
|
||||
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
|
||||
@@ -15,6 +16,15 @@ import {
|
||||
RECENTS_SECTION,
|
||||
} from './constants';
|
||||
|
||||
// search() lives in the logs condition builder only; traces and metrics reject it
|
||||
// as an unsupported operator. Every other function is implemented for all signals.
|
||||
export function isSupportedFunction(
|
||||
functionName: string,
|
||||
signal: SignalType,
|
||||
): boolean {
|
||||
return functionName !== QUERY_BUILDER_FUNCTIONS.SEARCH || signal === 'logs';
|
||||
}
|
||||
|
||||
export interface FieldContextPrefixMatch {
|
||||
context: string;
|
||||
remainder: string;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
completionStatus,
|
||||
currentCompletions,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
|
||||
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { RECENTS_DISPLAY_CAP, RECENTS_SECTION } from '../QuerySearch/constants';
|
||||
import QuerySearch from '../QuerySearch/QuerySearch';
|
||||
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
|
||||
|
||||
const CM_ROOT_SELECTOR = '.cm-editor';
|
||||
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
|
||||
const TOOLTIP_SELECTOR = '.cm-tooltip-autocomplete';
|
||||
const COMPLETION_LABEL_SELECTOR = '.cm-completionLabel';
|
||||
const DELETE_BUTTON_SELECTOR = '.cm-recent-delete';
|
||||
|
||||
const FRONTEND_FILTER = "service.name = 'frontend'";
|
||||
const STATUS_CODE_FILTER = "http.status_code = '500'";
|
||||
const TRACES_FILTER = "name = 'HTTP GET'";
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): { dashboardData: undefined } => ({
|
||||
dashboardData: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { keys: {} } },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderLogsSearch(onChange: (value: string) => void = jest.fn()): void {
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function saveLogsRecent(expression: string): void {
|
||||
recentQueriesStore.save({ signal: 'logs', filter: { expression } });
|
||||
}
|
||||
|
||||
function getEditorView(): EditorView | null {
|
||||
const root = document.querySelector<HTMLElement>(CM_ROOT_SELECTOR);
|
||||
return root ? EditorView.findFromDOM(root) : null;
|
||||
}
|
||||
|
||||
function getDocText(): string {
|
||||
return getEditorView()?.state.doc.toString() ?? '';
|
||||
}
|
||||
|
||||
function isCompletionOpen(): boolean {
|
||||
const view = getEditorView();
|
||||
return !!view && completionStatus(view.state) === 'active';
|
||||
}
|
||||
|
||||
// Reads recents from completion state, not the tooltip: the tooltip is a later render
|
||||
// pass over this same state, so going to the source drops a layer of timing.
|
||||
function getRecentLabels(): string[] {
|
||||
const view = getEditorView();
|
||||
if (!view) {
|
||||
return [];
|
||||
}
|
||||
return currentCompletions(view.state)
|
||||
.filter((completion) => completion.section === RECENTS_SECTION)
|
||||
.map((completion) => completion.label);
|
||||
}
|
||||
|
||||
async function renderAndFocus(
|
||||
onChange: (value: string) => void = jest.fn(),
|
||||
): Promise<HTMLElement> {
|
||||
renderLogsSearch(onChange);
|
||||
|
||||
const editor = await waitFor(
|
||||
() => {
|
||||
const element = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(element).toBeInTheDocument();
|
||||
return element as HTMLElement;
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
await userEvent.click(editor);
|
||||
return editor;
|
||||
}
|
||||
|
||||
// Re-requests completions while waiting: typing and the async fetches can close the popup.
|
||||
function waitForRecents(
|
||||
assertLabels: (labels: string[]) => void,
|
||||
): Promise<void> {
|
||||
return waitFor(
|
||||
() => {
|
||||
const view = getEditorView();
|
||||
if (view && !isCompletionOpen()) {
|
||||
startCompletion(view);
|
||||
}
|
||||
assertLabels(getRecentLabels());
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
function openRecents(): Promise<void> {
|
||||
return waitForRecents((labels) => expect(labels.length).toBeGreaterThan(0));
|
||||
}
|
||||
|
||||
function waitForPopupElement(
|
||||
find: () => HTMLElement | null | undefined,
|
||||
): Promise<HTMLElement> {
|
||||
return waitFor(
|
||||
() => {
|
||||
const view = getEditorView();
|
||||
if (view && !isCompletionOpen()) {
|
||||
startCompletion(view);
|
||||
}
|
||||
const node = find();
|
||||
expect(node).toBeTruthy();
|
||||
return node as HTMLElement;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
describe('QuerySearch recent searches', () => {
|
||||
beforeEach(() => {
|
||||
recentQueriesStore.useRecentQueriesStore.setState({ buckets: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows a saved recent query under "Recent searches" on focus', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
await waitForRecents((labels) =>
|
||||
expect(labels).toStrictEqual([FRONTEND_FILTER]),
|
||||
);
|
||||
|
||||
const view = getEditorView() as EditorView;
|
||||
const [recent] = currentCompletions(view.state);
|
||||
expect(recent.section).toBe(RECENTS_SECTION);
|
||||
});
|
||||
|
||||
it('filters recents by substring as the user types', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
saveLogsRecent(STATUS_CODE_FILTER);
|
||||
|
||||
const editor = await renderAndFocus();
|
||||
await openRecents();
|
||||
await userEvent.type(editor, 'status_code');
|
||||
|
||||
await waitForRecents((labels) =>
|
||||
expect(labels).toStrictEqual([STATUS_CODE_FILTER]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not surface recents saved under a different signal', async () => {
|
||||
recentQueriesStore.save({
|
||||
signal: 'traces',
|
||||
filter: { expression: TRACES_FILTER },
|
||||
});
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
await waitForRecents((labels) =>
|
||||
expect(labels).toStrictEqual([FRONTEND_FILTER]),
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes a recent that exactly matches the current editor text', async () => {
|
||||
const supersetFilter = `${FRONTEND_FILTER} AND ${STATUS_CODE_FILTER}`;
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
saveLogsRecent(supersetFilter);
|
||||
|
||||
const editor = await renderAndFocus();
|
||||
await openRecents();
|
||||
await userEvent.type(editor, FRONTEND_FILTER);
|
||||
|
||||
await waitForRecents((labels) =>
|
||||
expect(labels).toStrictEqual([supersetFilter]),
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the dropdown at RECENTS_DISPLAY_CAP entries, newest first', async () => {
|
||||
const filters = Array.from(
|
||||
{ length: RECENTS_DISPLAY_CAP + 1 },
|
||||
(_, index) => `attribute_${index + 1} = 'v'`,
|
||||
);
|
||||
filters.forEach((filter) => saveLogsRecent(filter));
|
||||
const expectedLabels = [...filters].reverse().slice(0, RECENTS_DISPLAY_CAP);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
await waitForRecents((labels) =>
|
||||
expect(labels).toStrictEqual(expectedLabels),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the full expression to the editor when a recent is clicked', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
const onChange = jest.fn();
|
||||
await renderAndFocus(onChange);
|
||||
await openRecents();
|
||||
|
||||
const option = await waitForPopupElement(() =>
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
|
||||
).find((element) => element.textContent === FRONTEND_FILTER),
|
||||
);
|
||||
// fireEvent: userEvent's pointerdown blurs the editor, closing the popup before CM's mousedown apply.
|
||||
fireEvent.mouseDown(option);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getDocText()).toBe(FRONTEND_FILTER);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(FRONTEND_FILTER);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(TOOLTIP_SELECTOR)).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a recent from the dropdown and the store when delete is clicked', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
const deleteButton = await waitForPopupElement(() =>
|
||||
document.querySelector<HTMLElement>(DELETE_BUTTON_SELECTOR),
|
||||
);
|
||||
|
||||
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(recentQueriesStore.list('logs')).toHaveLength(0);
|
||||
expect(getRecentLabels()).not.toContain(FRONTEND_FILTER);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
expect(getDocText()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import FilterQueryLexer from 'parser/FilterQueryLexer';
|
||||
import FilterQueryParser, {
|
||||
AndExpressionContext,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { cloneDeep, isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import {
|
||||
listRolesSuccessResponse,
|
||||
managedRoles,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -18,17 +21,24 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
serviceAccountRoles: [
|
||||
{
|
||||
id: 'sar-admin-1',
|
||||
serviceAccountId: 'sa-1',
|
||||
roleId: managedRoles[0].id,
|
||||
role: { ...managedRoles[0], transactionGroups: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
@@ -58,22 +68,13 @@ function setupBaseHandlers(): void {
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import {
|
||||
listRolesSuccessResponse,
|
||||
managedRoles,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -10,23 +13,33 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
|
||||
|
||||
const ADMIN_ASSIGNMENT_ID = 'sar-admin-1';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
serviceAccountRoles: [
|
||||
{
|
||||
id: ADMIN_ASSIGNMENT_ID,
|
||||
serviceAccountId: 'sa-1',
|
||||
roleId: managedRoles[0].id,
|
||||
role: { ...managedRoles[0], transactionGroups: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const deletedAccountResponse = {
|
||||
...activeAccountResponse,
|
||||
id: 'sa-2',
|
||||
status: 'DELETED',
|
||||
serviceAccountRoles: [],
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
@@ -58,22 +71,13 @@ describe('ServiceAccountDrawer', () => {
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
@@ -136,11 +140,14 @@ describe('ServiceAccountDrawer', () => {
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
|
||||
);
|
||||
}),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -159,7 +166,8 @@ describe('ServiceAccountDrawer', () => {
|
||||
await waitFor(() => {
|
||||
expect(roleSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
serviceAccountId: 'sa-1',
|
||||
roleId: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
}),
|
||||
);
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
@@ -174,11 +182,14 @@ describe('ServiceAccountDrawer', () => {
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
|
||||
);
|
||||
}),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (req, res, ctx) => {
|
||||
deleteSpy(req.params.id);
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -198,7 +209,7 @@ describe('ServiceAccountDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
expect(deleteSpy).toHaveBeenCalledWith(ADMIN_ASSIGNMENT_ID);
|
||||
expect(roleSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -246,9 +257,6 @@ describe('ServiceAccountDrawer', () => {
|
||||
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get('*/api/v1/service_accounts/sa-2/roles', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer({ account: 'sa-2' });
|
||||
@@ -312,22 +320,13 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
@@ -410,14 +409,17 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let roleAddCallCount = 0;
|
||||
|
||||
// First call → 429, second call → 200
|
||||
// First call → 429, second call → 201
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
|
||||
roleAddCallCount += 1;
|
||||
if (roleAddCallCount === 1) {
|
||||
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useRecoverFromEmptyPage,
|
||||
UseRecoverFromEmptyPageParams,
|
||||
} from '../useRecoverFromEmptyPage';
|
||||
|
||||
const REPLACE = { history: 'replace' };
|
||||
|
||||
function renderRecovery(
|
||||
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
|
||||
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
|
||||
const setPage = jest.fn();
|
||||
const props: UseRecoverFromEmptyPageParams = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
rowCount: 10,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
setPage,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
|
||||
{ initialProps: props },
|
||||
);
|
||||
|
||||
return {
|
||||
setPage,
|
||||
rerender: (next?: unknown): void =>
|
||||
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRecoverFromEmptyPage', () => {
|
||||
it('leaves the page alone while it still holds rows', () => {
|
||||
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the page alone on page 1 with no rows at all', () => {
|
||||
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jumps to the last page that holds data when the page is out of range', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
});
|
||||
|
||||
it('replaces the history entry so the back button does not return to the empty page', () => {
|
||||
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when the total is unknown', () => {
|
||||
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('steps back one page when the total claims the page should have data', () => {
|
||||
// total says 100 rows exist, yet page 5 came back empty — step back rather
|
||||
// than stall on a page the query cannot actually serve.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one', () => {
|
||||
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when pageSize is zero', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 0,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('waits for the request to settle before moving the user', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one even when the query failed', () => {
|
||||
// A negative offset is what made the request fail (400 "offset cannot be
|
||||
// negative"), so retrying the same page loops forever — clamp regardless.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one while the query is still in flight', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: -2,
|
||||
rowCount: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('keeps the page when the query failed so a retry lands where the user was', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps a page below the first one exactly once while the request settles', () => {
|
||||
// The clamp runs ahead of both gates, so a request settling underneath an
|
||||
// uncorrected page must not re-issue the same history rewrite.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops correcting once the corrected page comes back with rows', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
|
||||
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
|
||||
rerender({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 5,
|
||||
total: 25,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not correct again while the same page is still being observed', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A refetch cycle that leaves the page untouched — the correction is already in flight.
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: true,
|
||||
});
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
|
||||
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
|
||||
// page at a time would cost a request per hop, so bail out to page 1 instead.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 40,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 400,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
|
||||
|
||||
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
|
||||
|
||||
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
|
||||
expect(setPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('corrects again when the user returns to a page that is still empty', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
|
||||
|
||||
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
|
||||
});
|
||||
|
||||
it('does not re-run the correction when setPage is a fresh function each render', () => {
|
||||
// The hook reads setPage through a ref, so an inline arrow must not turn the
|
||||
// ungated `page < 1` clamp into a per-render history rewrite.
|
||||
const setPage = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
() =>
|
||||
useRecoverFromEmptyPage({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: false,
|
||||
setPage: (nextPage, options): void => setPage(nextPage, options),
|
||||
}),
|
||||
{ initialProps: undefined },
|
||||
);
|
||||
|
||||
rerender(undefined);
|
||||
rerender(undefined);
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useStableTotalCount,
|
||||
UseStableTotalCountParams,
|
||||
} from '../useStableTotalCount';
|
||||
|
||||
function renderStableTotalCount(initial: UseStableTotalCountParams): {
|
||||
result: { current: number };
|
||||
rerender: (next: UseStableTotalCountParams) => void;
|
||||
} {
|
||||
const { result, rerender } = renderHook(
|
||||
(params: UseStableTotalCountParams) => useStableTotalCount(params),
|
||||
{ initialProps: initial },
|
||||
);
|
||||
|
||||
return { result, rerender };
|
||||
}
|
||||
|
||||
describe('useStableTotalCount', () => {
|
||||
it('reports the total once the load settles', () => {
|
||||
const { result } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps the last count while the same list refetches', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
|
||||
it('forgets the count when the list changes', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the new list total once it arrives', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
|
||||
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
|
||||
|
||||
expect(result.current).toBe(25);
|
||||
});
|
||||
|
||||
it('reports zero for a list that is genuinely empty', () => {
|
||||
const { result } = renderStableTotalCount({
|
||||
total: 0,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('holds nothing back when no reset key is given', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: undefined,
|
||||
});
|
||||
|
||||
// Without a key there is no context to compare, so the cache behaves as it
|
||||
// always did: it survives the refetch.
|
||||
rerender({ total: 0, isLoading: true, resetKey: undefined });
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useRecoverFromEmptyPage';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -285,6 +286,51 @@ export * from './useTableParams';
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*
|
||||
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
|
||||
* current history entry instead of pushing a new one. Use `replace` for corrections the user
|
||||
* did not ask for — otherwise the back button walks straight back into the state that was just
|
||||
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
|
||||
*
|
||||
* @example useRecoverFromEmptyPage — send the user back to a page that has data
|
||||
*
|
||||
* When rows disappear underneath the current page (filters narrowed, time range moved, items
|
||||
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
|
||||
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
|
||||
* button does not return to the empty page.
|
||||
*
|
||||
* Correction rules:
|
||||
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
|
||||
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
|
||||
* confirm the page is empty — deferring to it would strand the user on a permanent error.
|
||||
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
|
||||
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
|
||||
* lands on page 1; and when `total` claims this page should have had rows it steps back a
|
||||
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
|
||||
* badly inflated `total` cannot walk the user down one request at a time.
|
||||
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
|
||||
* is genuinely nothing to show).
|
||||
*
|
||||
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
|
||||
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
|
||||
*
|
||||
* ```tsx
|
||||
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
|
||||
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
|
||||
*
|
||||
* useRecoverFromEmptyPage({
|
||||
* page,
|
||||
* pageSize: limit,
|
||||
* rowCount: data?.rows.length ?? 0,
|
||||
* total: data?.total ?? 0,
|
||||
* isFetching: isLoading || isFetching,
|
||||
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
|
||||
* isDisabled: isError,
|
||||
* setPage,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { SetPageOptions } from './useTableParams';
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
|
||||
|
||||
/**
|
||||
* How many single-page step-backs to attempt before giving up and going to page 1.
|
||||
*
|
||||
* A step-back only happens when `total` claims the current page should hold data but the
|
||||
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
|
||||
* number would otherwise walk the user down one page at a time behind a spinner.
|
||||
*/
|
||||
const MAX_STEP_BACKS = 2;
|
||||
|
||||
type Correction = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type UseRecoverFromEmptyPageParams = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
rowCount: number;
|
||||
total: number;
|
||||
isFetching: boolean;
|
||||
isDisabled?: boolean;
|
||||
setPage: (page: number, options?: SetPageOptions) => void;
|
||||
};
|
||||
|
||||
export function useRecoverFromEmptyPage({
|
||||
page,
|
||||
pageSize,
|
||||
rowCount,
|
||||
total,
|
||||
isFetching,
|
||||
isDisabled = false,
|
||||
setPage,
|
||||
}: UseRecoverFromEmptyPageParams): void {
|
||||
const setPageRef = useRef(setPage);
|
||||
const lastCorrectionRef = useRef<Correction | null>(null);
|
||||
const stepBacksRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setPageRef.current = setPage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
|
||||
lastCorrectionRef.current = null;
|
||||
}
|
||||
|
||||
const correctTo = (nextPage: number): boolean => {
|
||||
if (lastCorrectionRef.current?.to === nextPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastCorrectionRef.current = { from: page, to: nextPage };
|
||||
setPageRef.current(nextPage, REPLACE_HISTORY);
|
||||
return true;
|
||||
};
|
||||
|
||||
// A page below the first one is invalid on its own terms — it usually maps to a
|
||||
// negative offset the API rejects outright, so waiting for a response that will
|
||||
// never arrive (or trusting a failed one) would strand the user for good.
|
||||
if (page < FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
void correctTo(FIRST_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFetching || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The page has data, or there is genuinely nothing to show anywhere.
|
||||
if (rowCount > 0 || page === FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(page);
|
||||
const lastPageWithData =
|
||||
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
|
||||
const nextPage = Math.max(
|
||||
FIRST_PAGE,
|
||||
Math.min(lastPageWithData, currentPage - 1),
|
||||
);
|
||||
|
||||
// `total` disagrees with the response: it says this page should have rows, so the
|
||||
// only safe move is one page back. Cap how often that repeats — every hop is a
|
||||
// request, and a badly inflated `total` would otherwise crawl down from page 40.
|
||||
const isStepBack = nextPage === currentPage - 1;
|
||||
|
||||
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
|
||||
if (correctTo(FIRST_PAGE)) {
|
||||
stepBacksRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (correctTo(nextPage) && isStepBack) {
|
||||
stepBacksRef.current += 1;
|
||||
}
|
||||
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
export type UseStableTotalCountParams = {
|
||||
total: number | undefined;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* Identifies the list being counted. When it changes, the cached count is
|
||||
* dropped so the previous list's page count cannot outlive it.
|
||||
*/
|
||||
resetKey: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds on to the last non-zero total so the pagination does not flash while the
|
||||
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
|
||||
*/
|
||||
export function useStableTotalCount({
|
||||
total,
|
||||
isLoading,
|
||||
resetKey,
|
||||
}: UseStableTotalCountParams): number {
|
||||
const prevTotalRef = useRef(total || 0);
|
||||
const prevResetKeyRef = useRef(resetKey);
|
||||
|
||||
if (prevResetKeyRef.current !== resetKey) {
|
||||
prevResetKeyRef.current = resetKey;
|
||||
prevTotalRef.current = 0;
|
||||
}
|
||||
|
||||
if (total && total > 0) {
|
||||
prevTotalRef.current = total;
|
||||
}
|
||||
|
||||
return isLoading ? prevTotalRef.current : total || 0;
|
||||
}
|
||||
@@ -29,12 +29,16 @@ type Defaults = {
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
export type SetPageOptions = {
|
||||
history?: 'push' | 'replace';
|
||||
};
|
||||
|
||||
export type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
expanded: ExpandedState;
|
||||
setPage: (p: number) => void;
|
||||
setPage: (p: number, options?: SetPageOptions) => void;
|
||||
setLimit: (l: number) => void;
|
||||
setOrderBy: (s: SortState | null) => void;
|
||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||
@@ -249,6 +253,17 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const setUrlPageWithOptions = useCallback(
|
||||
(page: number, options?: SetPageOptions): void => {
|
||||
void setUrlPage(page, options);
|
||||
},
|
||||
[setUrlPage],
|
||||
);
|
||||
|
||||
const setLocalPageValue = useCallback((page: number): void => {
|
||||
setLocalPage(page);
|
||||
}, []);
|
||||
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
@@ -303,7 +318,7 @@ export function useTableParams(
|
||||
limit: useUrlForLimit ? urlLimit : localLimit,
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
|
||||
@@ -41,6 +41,7 @@ export const QUERY_BUILDER_FUNCTIONS = {
|
||||
HASANY: 'hasAny',
|
||||
HASALL: 'hasAll',
|
||||
HASTOKEN: 'hasToken',
|
||||
SEARCH: 'search',
|
||||
};
|
||||
|
||||
export function negateOperator(operatorOrFunction: string): string {
|
||||
|
||||
@@ -13,7 +13,6 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
|
||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||
|
||||
@@ -9,6 +9,7 @@ const fieldContextToSuggestionMap: Record<
|
||||
[TelemetrytypesFieldContextDTO.span]: 'span',
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.trace]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import useUpdatedQuery from '../useResolveQuery';
|
||||
|
||||
const mockGetSubstituteVars = jest.fn();
|
||||
const mockDynamicVariables: unknown[] = [];
|
||||
|
||||
jest.mock('api/dashboard/substitute_vars', () => ({
|
||||
getSubstituteVars: (...args: unknown[]): unknown =>
|
||||
mockGetSubstituteVars(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/v5/v5', () => ({
|
||||
prepareQueryRangePayloadV5: (): { queryPayload: unknown } => ({
|
||||
queryPayload: { start: 0, end: 1 },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
() => ({
|
||||
mapQueryDataFromApi: (): Query => ({ resolved: true }) as unknown as Query,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
|
||||
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): unknown => ({
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
}),
|
||||
}));
|
||||
|
||||
const QUERY = { builder: { queryData: [] } } as unknown as Query;
|
||||
|
||||
const WIDGET_CONFIG = {
|
||||
query: QUERY,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
timePreferance: 'GLOBAL_TIME' as const,
|
||||
};
|
||||
|
||||
describe('useResolveQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDynamicVariables.length = 0;
|
||||
});
|
||||
|
||||
it('skips the substitute_vars round-trip when there are no variables', async () => {
|
||||
const { result } = renderHook(() => useUpdatedQuery(), {
|
||||
wrapper: MockQueryClientProvider,
|
||||
});
|
||||
|
||||
const resolved = await result.current.getUpdatedQuery({
|
||||
widgetConfig: WIDGET_CONFIG,
|
||||
});
|
||||
|
||||
expect(mockGetSubstituteVars).not.toHaveBeenCalled();
|
||||
expect(resolved).toBe(QUERY);
|
||||
});
|
||||
|
||||
it('resolves through substitute_vars when the dashboard has variables', async () => {
|
||||
mockGetSubstituteVars.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { compositeQuery: {} },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdatedQuery(), {
|
||||
wrapper: MockQueryClientProvider,
|
||||
});
|
||||
|
||||
const resolved = await result.current.getUpdatedQuery({
|
||||
widgetConfig: WIDGET_CONFIG,
|
||||
dashboardData: {
|
||||
data: {
|
||||
variables: {
|
||||
env: { name: 'env', selectedValue: 'prod' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
|
||||
expect(resolved).toStrictEqual({ resolved: true });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -46,13 +47,21 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
widgetConfig,
|
||||
dashboardData,
|
||||
}: UseUpdatedQueryOptions): Promise<Query> => {
|
||||
const variables = getDashboardVariables(dashboardData?.data?.variables);
|
||||
|
||||
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
|
||||
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
|
||||
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
|
||||
return widgetConfig.query;
|
||||
}
|
||||
|
||||
// Prepare query payload with resolved variables
|
||||
const { queryPayload } = prepareQueryRangePayloadV5({
|
||||
query: widgetConfig.query,
|
||||
graphType: getGraphType(widgetConfig.panelTypes),
|
||||
selectedTime: widgetConfig.timePreferance,
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(dashboardData?.data?.variables),
|
||||
variables,
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
dynamicVariables: dashboardDynamicVariables,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
useCalculatedPageSize,
|
||||
useHiddenColumnIds,
|
||||
useRecoverFromEmptyPage,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -136,6 +137,7 @@ export function K8sBaseList<
|
||||
page: currentPage,
|
||||
limit: currentPageSize,
|
||||
setLimit,
|
||||
setPage,
|
||||
} = useTableParams(
|
||||
{
|
||||
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
@@ -243,6 +245,16 @@ export function K8sBaseList<
|
||||
const totalCount = data?.total || 0;
|
||||
const hasFilters = !!expression?.trim();
|
||||
|
||||
useRecoverFromEmptyPage({
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize,
|
||||
rowCount: pageData.length,
|
||||
total: totalCount,
|
||||
isFetching: isLoading || isFetching,
|
||||
isDisabled: isError || Boolean(data?.error),
|
||||
setPage,
|
||||
});
|
||||
|
||||
const getGroupKeyFn = useCallback(
|
||||
(item: T) => getGroupedByMeta(item, groupBy),
|
||||
[groupBy],
|
||||
|
||||
@@ -51,4 +51,5 @@
|
||||
gap: var(--spacing-3);
|
||||
max-width: 500px;
|
||||
padding: 24px;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
|
||||
import styles from './K8sExpandedRow.module.scss';
|
||||
import { buildExpressionFromGroupMeta } from './utils';
|
||||
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
@@ -591,12 +591,14 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
describe('with empty data', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
@@ -605,6 +607,7 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
@@ -625,6 +628,177 @@ describe('K8sBaseList', () => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not rewrite the page when already on the first page', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page beyond the end of the list', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
|
||||
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `pod-${index + 1}`,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// Offset-aware on purpose: a mock that answers empty for every offset would let
|
||||
// the assertions pass against a page the recovery has already moved on from.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
|
||||
data: rows.slice(offset, offset + limit),
|
||||
total: rows.length,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '7', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should send the user back to the last page holding data', async () => {
|
||||
// The rows of page 3 on screen are what proves the recovery settled there,
|
||||
// rather than passing through on its way somewhere else.
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('should correct the page in a single hop', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
// Only the original out-of-range page and the corrected one are requested.
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.map((call) => call[0].offset),
|
||||
).toStrictEqual([60, 20]);
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === '3',
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page below the first one', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// page=0 turns into offset=-10, which the API rejects outright — the list
|
||||
// can only recover by clamping the page, never by reading the response.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
|
||||
if (offset < 0) {
|
||||
throw new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: 'invalid_input',
|
||||
message: 'offset cannot be negative',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { data: [{ id: 'pod-1' }], total: 1, error: null };
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '0', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject the request that carried the negative offset', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchListDataMock.mock.results[0].value as Promise<unknown>,
|
||||
).rejects.toThrow('offset cannot be negative');
|
||||
});
|
||||
|
||||
it('should clamp the page to the first one even though the request failed', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Page 1 is the default, so the correction drops the param rather than
|
||||
// writing `page=1`.
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection).toBeDefined();
|
||||
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
|
||||
it('should refetch with a non-negative offset after clamping', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pod-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with error response', () => {
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface K8sDetailsFilters {
|
||||
export interface K8sDetailsWidgetInfo {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type GetEntityQueryPayload<T> = (
|
||||
|
||||
@@ -94,43 +94,59 @@ export const clusterWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#cpu-usage-allocatable',
|
||||
description:
|
||||
'Avg, max and min pod CPU usage across the cluster against total allocatable CPU.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage, allocatable',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#memory-usage-allocatable',
|
||||
description:
|
||||
'Avg, max and min pod memory usage against allocatable memory; usage closing in on it risks evictions.',
|
||||
},
|
||||
{
|
||||
title: 'Ready Nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#ready-nodes',
|
||||
description:
|
||||
'Nodes currently reporting Ready; a line dropping out means that node stopped accepting pods.',
|
||||
},
|
||||
{
|
||||
title: 'NotReady Nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#notready-nodes',
|
||||
description:
|
||||
'Nodes whose kubelet reports unhealthy; their pods are evicted after the toleration window.',
|
||||
},
|
||||
{
|
||||
title: 'Deployments available and desired',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#deployments-available-and-desired',
|
||||
description:
|
||||
'Desired replicas versus pods available past minReadySeconds; a persistent gap means a stuck rollout.',
|
||||
},
|
||||
{
|
||||
title: 'Statefulset pods',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#statefulset-pods',
|
||||
description:
|
||||
'Desired, current, ready and updated pod counts per StatefulSet; ready below desired means readiness failures.',
|
||||
},
|
||||
{
|
||||
title: 'Daemonset nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#daemonset-nodes',
|
||||
description:
|
||||
'Desired, current and ready node counts per DaemonSet; gaps mean node agents are missing.',
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#jobs',
|
||||
description:
|
||||
'Active, succeeded, failed and desired successful pod counts per Job; non-zero failed needs triage.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -76,23 +76,31 @@ export const daemonSetWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#cpu-usage-request-limits',
|
||||
description:
|
||||
'Total CPU usage of the DaemonSet pods against their aggregate CPU requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#memory-usage-request-limits',
|
||||
description:
|
||||
'Total memory usage of the DaemonSet pods against their aggregate memory requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/daemonsets/#network-io',
|
||||
description:
|
||||
'Transmit and receive throughput per interface across all pods of the DaemonSet.',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#network-errors-count',
|
||||
description:
|
||||
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -76,23 +76,31 @@ export const deploymentWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#cpu-usage-request-limits',
|
||||
description:
|
||||
'Total CPU usage of the Deployment pods against their aggregate CPU requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#memory-usage-request-limits',
|
||||
description:
|
||||
'Total memory usage of the Deployment pods against their aggregate memory requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/deployments/#network-io',
|
||||
description:
|
||||
'Transmit and receive throughput per interface across all pods of the Deployment.',
|
||||
},
|
||||
{
|
||||
title: 'Network error count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#network-error-count',
|
||||
description:
|
||||
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ function EntityLogsContent({
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
// TODO: Move away from using onAddToQuery after old drawer cleanup
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
@@ -104,6 +105,21 @@ function EntityLogsContent({
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const onApplyLogFilter = useCallback(
|
||||
(expression: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const newUser = userExpression.trim()
|
||||
? `${userExpression} AND ${expression}`
|
||||
: expression;
|
||||
|
||||
querySearchOnRun(newUser);
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
|
||||
},
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const {
|
||||
logs,
|
||||
loadMoreLogs,
|
||||
@@ -328,6 +344,7 @@ function EntityLogsContent({
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
|
||||
getContainer={(): HTMLElement =>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
|
||||
|
||||
import { getDrawerDurationMs } from 'container/InfraMonitoringK8sV2/Base/useDrawerLifecycleStore';
|
||||
import { K8sDetailsWidgetInfo } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
import ChartHeader from './ChartHeader';
|
||||
@@ -41,11 +42,7 @@ import ChartTooltipFooter from './ChartTooltipFooter';
|
||||
interface EntityMetricsProps<T> {
|
||||
entity: T;
|
||||
eventEntity: string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
entityWidgetInfo: K8sDetailsWidgetInfo[];
|
||||
getEntityQueryPayload: (
|
||||
node: T,
|
||||
start: number,
|
||||
@@ -219,6 +216,7 @@ function EntityMetrics<T>({
|
||||
<ChartHeader
|
||||
title={entityWidgetInfo[idx].title}
|
||||
docPath={entityWidgetInfo[idx].docPath}
|
||||
tooltip={entityWidgetInfo[idx].description}
|
||||
metricsExplorerUrl={
|
||||
queryPayloads[idx] && queryPayloads[idx].graphType !== PANEL_TYPES.TABLE
|
||||
? getMetricsExplorerUrl({
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from './hooks';
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
@@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
void setSelectedCategory(key as string);
|
||||
void setOrderBy(null);
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
|
||||
@@ -74,21 +74,27 @@ export const jobWidgetInfo = [
|
||||
title: 'CPU usage',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#cpu-usage',
|
||||
description: 'CPU consumption in cores summed across the pods of the Job.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#memory-usage',
|
||||
description: 'Memory consumption in bytes summed across the pods of the Job.',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#network-io',
|
||||
description:
|
||||
'Transmit and receive throughput per interface across all pods of the Job.',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#network-errors-count',
|
||||
description:
|
||||
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -113,53 +113,73 @@ export const namespaceWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores-1',
|
||||
description:
|
||||
'Avg, max and min pod CPU usage in the namespace against the sum of container CPU requests.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#memory-usage-bytes',
|
||||
description:
|
||||
'Pod memory usage, working set and RSS in the namespace against the sum of container memory requests.',
|
||||
},
|
||||
{
|
||||
title: 'Pods CPU (top 10)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#pods-cpu-top-10',
|
||||
description:
|
||||
'CPU consumption in cores for the ten highest-consuming pods in the namespace.',
|
||||
},
|
||||
{
|
||||
title: 'Pods Memory (top 10)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#pods-memory-top-10',
|
||||
description:
|
||||
'Memory consumption in bytes for the ten highest-consuming pods in the namespace.',
|
||||
},
|
||||
{
|
||||
title: 'Network rate',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#network-rate',
|
||||
description:
|
||||
'Transmit and receive throughput per interface across the pods of the namespace.',
|
||||
},
|
||||
{
|
||||
title: 'Network errors',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#network-errors',
|
||||
description:
|
||||
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
|
||||
},
|
||||
{
|
||||
title: 'StatefulSets (pods)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#statefulsets',
|
||||
description:
|
||||
'Desired, current and updated pod counts per StatefulSet in the namespace, revealing stalled rollouts.',
|
||||
},
|
||||
{
|
||||
title: 'ReplicaSets (pods)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#replicasets',
|
||||
description:
|
||||
'Desired versus available replicas per ReplicaSet in the namespace, revealing pods stuck pending.',
|
||||
},
|
||||
{
|
||||
title: 'DaemonSets (nodes)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#daemonsets',
|
||||
description:
|
||||
'Desired, current, ready and misscheduled node counts per DaemonSet in the namespace.',
|
||||
},
|
||||
{
|
||||
title: 'Deployments (pods)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#deployments',
|
||||
description:
|
||||
'Desired and available replicas with utilization percentage per Deployment in the namespace.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1617,13 +1637,13 @@ export const getNamespaceMetricsQueryPayload = (
|
||||
reduceTo: ReduceOperators.LAST,
|
||||
spaceAggregation: 'max',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'latest',
|
||||
},
|
||||
],
|
||||
queryFormulas: [
|
||||
{
|
||||
disabled: false,
|
||||
expression: 'A/B',
|
||||
expression: '(B/A) * 100',
|
||||
legend: 'util %',
|
||||
queryName: 'F1',
|
||||
},
|
||||
|
||||
@@ -58,52 +58,71 @@ export const nodeWidgetInfo = [
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores-1',
|
||||
description:
|
||||
'Avg, max and min node CPU usage against allocatable capacity and the CPU requests scheduled on the node.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#memory-usage-bytes',
|
||||
description:
|
||||
'Node memory usage, working set and RSS against allocatable memory and scheduled memory requests.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Usage (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-',
|
||||
description:
|
||||
'Node CPU usage as a percentage of allocatable capacity and of the CPU requests scheduled on the node.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#memory-usage-',
|
||||
description:
|
||||
'Node memory usage as a percentage of allocatable memory and of the memory requests scheduled on the node.',
|
||||
},
|
||||
{
|
||||
title: 'Pods by CPU (top 10)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#pods-by-cpu-top-10',
|
||||
description:
|
||||
'CPU consumption in cores for the ten highest-consuming pods on this node.',
|
||||
},
|
||||
{
|
||||
title: 'Pods by Memory (top 10)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#pods-by-memory-top-10',
|
||||
description:
|
||||
'Memory consumption in bytes for the ten highest-consuming pods on this node.',
|
||||
},
|
||||
{
|
||||
title: 'Network error count',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#network-error-count',
|
||||
description:
|
||||
'Per-interface network error counts by direction, from the kubelet error counters.',
|
||||
},
|
||||
{
|
||||
title: 'Network IO rate',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#network-io-rate',
|
||||
description:
|
||||
'Transmit and receive throughput per network interface on the node.',
|
||||
},
|
||||
{
|
||||
title: 'Filesystem usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/nodes/#filesystem-usage-bytes',
|
||||
description:
|
||||
'Capacity, available and used bytes for the primary filesystem of the node.',
|
||||
},
|
||||
{
|
||||
title: 'Filesystem usage (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#filesystem-usage-',
|
||||
description: 'Percentage of the nodefs filesystem currently consumed.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -68,73 +68,97 @@ export const podWidgetInfo = [
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
|
||||
description:
|
||||
'Avg, max and min CPU consumption of the pod in cores, showing how volatile it is.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#cpu-request-limit-utilization',
|
||||
description:
|
||||
'Pod CPU usage as a fraction of its total container CPU requests and limits, to spot throttling.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#memory-usage-bytes',
|
||||
description:
|
||||
'Avg, max and min memory consumption of the pod, including reclaimable page cache.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-request-limit-utilization',
|
||||
description:
|
||||
'Pod memory usage as a fraction of its total container memory requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'Memory by State',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#memory-by-state',
|
||||
description:
|
||||
'RSS, working set and cache memory of the pod, separating heap growth from file cache.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Major Page Faults',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-major-page-faults',
|
||||
description:
|
||||
'Major page fault rate of the pod; sustained values mean the working set is paging to disk.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Usage by Container (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#cpu-usage-by-container-cores',
|
||||
description:
|
||||
'CPU consumption in cores per container, showing which container drives the pod CPU.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#cpu-request-limit-utilization-by-container',
|
||||
description:
|
||||
'Each container CPU usage as a fraction of its own request and limit, to find the throttled one.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage by Container (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-usage-by-container-bytes',
|
||||
description: 'Usage, working set and RSS memory per container of the pod.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request, Limit Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-request-limit-utilization-by-container',
|
||||
description:
|
||||
'Each container memory usage as a fraction of its own request and limit; near 100% risks an OOMKill.',
|
||||
},
|
||||
{
|
||||
title: 'Network rate',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#network-rate',
|
||||
description: 'Pod network throughput in bytes/s by direction and interface.',
|
||||
},
|
||||
{
|
||||
title: 'Network errors',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#network-errors',
|
||||
description:
|
||||
'Network error counts on the pod interfaces; sustained non-zero values point to CNI or MTU issues.',
|
||||
},
|
||||
{
|
||||
title: 'File system (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#file-system-bytes',
|
||||
description:
|
||||
'Capacity, available and used bytes of the local filesystem of the pod.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -77,35 +77,47 @@ export const statefulSetWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#cpu-usage-request-limits',
|
||||
description:
|
||||
'Total CPU usage of the StatefulSet pods against their aggregate CPU requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'CPU request, limit util (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#cpu-request-limit-utilization-',
|
||||
description:
|
||||
'Average CPU usage of the StatefulSet as a percentage of its requests and of its limits.',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#memory-usage-request-limits',
|
||||
description:
|
||||
'Total memory usage of the StatefulSet pods against their aggregate memory requests and limits.',
|
||||
},
|
||||
{
|
||||
title: 'Memory request, limit util (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#memory-request-limit-utilization-',
|
||||
description:
|
||||
'Average memory usage as a percentage of requests and limits; above 100% of request means it exceeds its reservation.',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/statefulsets/#network-io',
|
||||
description:
|
||||
'Transmit and receive throughput per interface across all pods of the StatefulSet.',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#network-errors-count',
|
||||
description:
|
||||
'Per-pod-interface network error counts by direction and interface, reported by the kubelet.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -70,28 +70,38 @@ export const volumeWidgetInfo = [
|
||||
title: 'Volume available',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-available-1',
|
||||
description:
|
||||
'Free bytes on the volume over time; a steady decline forecasts when the volume fills up.',
|
||||
},
|
||||
{
|
||||
title: 'Volume capacity',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-capacity-1',
|
||||
description:
|
||||
'Total provisioned capacity of the volume in bytes, which steps up only when the PVC is resized.',
|
||||
},
|
||||
{
|
||||
title: 'Volume inodes used',
|
||||
yAxisUnit: 'short',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-used-1',
|
||||
description:
|
||||
'Inodes consumed on the volume filesystem; a rising line means many small files are being created.',
|
||||
},
|
||||
{
|
||||
title: 'Volume inodes',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-1',
|
||||
description:
|
||||
'Total inodes available on the volume filesystem, the reference for spotting inode exhaustion.',
|
||||
},
|
||||
{
|
||||
title: 'Volume inodes free',
|
||||
yAxisUnit: 'short',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-free-1',
|
||||
description:
|
||||
'Unallocated inodes on the volume; near zero, file creation fails with ENOSPC even with free bytes.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
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 { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
|
||||
import { K8sCategories } from '../constants';
|
||||
import InfraMonitoringK8s from '../InfraMonitoringK8s';
|
||||
|
||||
// Quick filters fire their own field APIs and are irrelevant to pagination.
|
||||
jest.mock('components/QuickFilters/QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="quick-filters" />,
|
||||
}));
|
||||
|
||||
// The list owns its own page recovery; stubbing it keeps the page param under the
|
||||
// sole control of the category handler being tested here.
|
||||
jest.mock('../Base/K8sDynamicList', () => ({
|
||||
__esModule: true,
|
||||
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
}));
|
||||
|
||||
// Analytics only; jsdom lacks the Performance navigation entries it reads.
|
||||
jest.mock('lib/navigation', () => ({
|
||||
getNavigationReferrer: (): string => 'direct',
|
||||
}));
|
||||
|
||||
function renderPage(
|
||||
queryParams: Record<string, string>,
|
||||
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
|
||||
): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouterV5>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<InfraMonitoringK8s />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</Provider>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</MemoryRouterV5>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('InfraMonitoringK8s', () => {
|
||||
describe('when the category changes from a page other than the first', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
beforeEach(async () => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
|
||||
renderPage(
|
||||
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
|
||||
onUrlUpdateMock,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`category-${K8sCategories.NODES}`);
|
||||
});
|
||||
|
||||
it('should drop the page so the new category starts at the first one', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
|
||||
// for an offset it may not have. The param is cleared rather than set to 1,
|
||||
// since an absent page already means the first one.
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch).toBeDefined();
|
||||
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the page size, which is not category specific', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave the page alone when the same category is clicked again', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const droppedPage = onUrlUpdateMock.mock.calls.some(
|
||||
(call) => !call[0].searchParams.has('page'),
|
||||
);
|
||||
|
||||
expect(droppedPage).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -800,26 +800,36 @@ export const podUtilizationByPodWidgetInfo = [
|
||||
title: 'CPU Limit Utilization By Pod Name',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '#cpu-limit-utilization-by-pod-name',
|
||||
description:
|
||||
'CPU usage against the CPU limit for each pod; near 100% means the kernel is throttling that pod.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request Utilization By Pod Name',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '#cpu-request-utilization-by-pod-name',
|
||||
description:
|
||||
'CPU usage against the CPU request for each pod; above 100% means the pod uses more than it reserved.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Limit Utilization By Pod Name',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '#memory-limit-utilization-by-pod-name',
|
||||
description:
|
||||
'Memory usage against the memory limit for each pod; near 100% means that pod is close to an OOMKill.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request Utilization By Pod Name',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '#memory-request-utilization-by-pod-name',
|
||||
description:
|
||||
'Memory usage against the memory request for each pod; above 100% means the pod exceeds its reservation.',
|
||||
},
|
||||
{
|
||||
title: 'FileSystem Usage Percentage By Pod Name',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '#filesystem-usage-percentage-by-pod-name',
|
||||
description:
|
||||
'Local and ephemeral filesystem fill level as a percentage of capacity for each pod.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
// do not use .withDefault here, this can cause bugs when
|
||||
// two hooks of nuqs define default twice, this is also
|
||||
// defined at useTableParams
|
||||
parseAsInteger.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next?: string) => void;
|
||||
}): JSX.Element => (
|
||||
<textarea
|
||||
aria-label="json-editor"
|
||||
data-testid="monaco"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../../LLMObservabilityAttributeMapping';
|
||||
import { SAMPLE_SPAN_JSON } from '../spanInputStorage';
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
makeTestResponse,
|
||||
mockGroups,
|
||||
TEST_ENDPOINT,
|
||||
} from '../../__tests__/fixtures';
|
||||
|
||||
const RESULT_SPAN = {
|
||||
attributes: {
|
||||
'my_company.llm.input': 'What is quantum computing?',
|
||||
'llm.input_messages': 'What is quantum computing?',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
'gen_ai.usage.total_tokens': 1250,
|
||||
'gen_ai.content.completion': 'Quantum computing leverages...',
|
||||
'gen_ai.content.prompt': 'What is quantum computing?',
|
||||
},
|
||||
resource: {
|
||||
'service.name': 'llm-gateway',
|
||||
'deployment.environment': 'production',
|
||||
},
|
||||
};
|
||||
|
||||
const EDITED_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"gen_ai.request.model": "claude-opus-5"
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "my-edited-gateway"
|
||||
}
|
||||
}`;
|
||||
|
||||
const SPAN_INPUT_KEY = LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN;
|
||||
|
||||
describe('TestTab — sample-span flow', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
remove(SPAN_INPUT_KEY);
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
);
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
it('runs the sample span through the mappers and renders the populated result', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.post(TEST_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeTestResponse([RESULT_SPAN]))),
|
||||
),
|
||||
);
|
||||
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
const runBtn = await screen.findByTestId('run-test-button');
|
||||
expect(screen.getByTestId('test-results-placeholder')).toBeInTheDocument();
|
||||
|
||||
await user.click(runBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('test-results'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
|
||||
'gen_ai.content.prompt',
|
||||
);
|
||||
expect(screen.getByText('populated')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a backend error and renders no results', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.post(TEST_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({ error: { message: 'span mapper test failed' } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
await user.click(await screen.findByTestId('run-test-button'));
|
||||
|
||||
await expect(screen.findByTestId('test-error')).resolves.toHaveTextContent(
|
||||
'span mapper test failed',
|
||||
);
|
||||
expect(screen.queryByTestId('test-results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists an edited span to local storage and restores it on remount', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { unmount } = render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
await screen.findByTestId('run-test-button');
|
||||
|
||||
const editor = screen.getByTestId('monaco');
|
||||
expect(editor).toHaveValue(SAMPLE_SPAN_JSON);
|
||||
|
||||
await user.clear(editor);
|
||||
await user.paste(EDITED_SPAN_JSON);
|
||||
|
||||
await waitFor(() => expect(get(SPAN_INPUT_KEY)).toBe(EDITED_SPAN_JSON), {
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
unmount();
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
await screen.findByTestId('run-test-button');
|
||||
expect(screen.getByTestId('monaco')).toHaveValue(EDITED_SPAN_JSON);
|
||||
});
|
||||
|
||||
it('resets to the sample span and clears the persisted input', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
set(SPAN_INPUT_KEY, EDITED_SPAN_JSON);
|
||||
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
const resetBtn = await screen.findByTestId('reset-template-button');
|
||||
|
||||
expect(screen.getByTestId('monaco')).toHaveValue(EDITED_SPAN_JSON);
|
||||
expect(resetBtn).toBeEnabled();
|
||||
|
||||
await user.click(resetBtn);
|
||||
|
||||
expect(screen.getByTestId('monaco')).toHaveValue(SAMPLE_SPAN_JSON);
|
||||
expect(get(SPAN_INPUT_KEY)).toBeFalsy();
|
||||
expect(resetBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
SpantypesSpanMapperTestSpanDTO as TestSpan,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Endpoint globs used by MSW handlers. The generated client hits relative
|
||||
@@ -12,6 +13,7 @@ export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
|
||||
export function mappersEndpoint(groupId: string): string {
|
||||
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
|
||||
}
|
||||
export const TEST_ENDPOINT = '*/api/v1/span_mapper_groups/test';
|
||||
|
||||
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
return {
|
||||
@@ -71,6 +73,13 @@ export function makeMappersResponse(mappers: Mapper[]): {
|
||||
return { status: 'ok', data: { items: mappers } };
|
||||
}
|
||||
|
||||
export function makeTestResponse(spans: TestSpan[]): {
|
||||
status: string;
|
||||
data: { spans: TestSpan[] };
|
||||
} {
|
||||
return { status: 'ok', data: { spans } };
|
||||
}
|
||||
|
||||
export const mockGroups: MapperGroup[] = [
|
||||
makeGroup({
|
||||
id: 'group-1',
|
||||
|
||||
@@ -9,7 +9,11 @@ function Overview(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
<DashboardContainer dashboard={dashboard} refetch={refetch} />
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
canEditDashboardOverride={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "llm-observability-overview",
|
||||
"orgId": "",
|
||||
"locked": true,
|
||||
"locked": false,
|
||||
"name": "AI Observability Overview",
|
||||
"schemaVersion": "v6",
|
||||
"source": "system",
|
||||
@@ -1146,4 +1146,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2780,68 +2780,94 @@ export const hostWidgetInfo = [
|
||||
title: 'CPU Usage',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#cpu-usage-1',
|
||||
description:
|
||||
'CPU time share per state (user, system, wait, steal, idle); sustained wait points to disk I/O blocking.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#memory-usage-1',
|
||||
description:
|
||||
'Physical memory bytes per state (used, cached, buffers, free); a climbing used line suggests a leak.',
|
||||
},
|
||||
{
|
||||
title: 'Disk Usage (%) by mountpoint',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/host-monitoring/#disk-usage--by-mountpoint',
|
||||
description:
|
||||
'Used space as a percentage of capacity for each mountpoint, one line per mountpoint.',
|
||||
},
|
||||
{
|
||||
title: 'System Load Average',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#system-load-average',
|
||||
description:
|
||||
'The 1m, 5m and 15m load averages together; 1m above 15m means load is building.',
|
||||
},
|
||||
{
|
||||
title: 'Network usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
title: 'Network usage',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-bytes',
|
||||
description:
|
||||
'Throughput in bytes/s per interface and direction, to spot NICs nearing rated bandwidth.',
|
||||
},
|
||||
{
|
||||
title: 'Network usage (packet/s)',
|
||||
yAxisUnit: 'pps',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-packetss',
|
||||
description:
|
||||
'Packets per second per interface and direction; a NIC can saturate on packet rate before bytes.',
|
||||
},
|
||||
{
|
||||
title: 'Network errors',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-errors',
|
||||
description:
|
||||
'Rate of interface-level network errors per interface and direction; any sustained value needs attention.',
|
||||
},
|
||||
{
|
||||
title: 'Network drops',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-drops',
|
||||
description:
|
||||
'Rate of dropped packets per interface and direction, usually buffer overflow rather than link errors.',
|
||||
},
|
||||
{
|
||||
title: 'Network connections',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-connections',
|
||||
description:
|
||||
'Active connection counts per protocol and state (ESTABLISHED, TIME_WAIT, SYN_RECV) to spot leaks and churn.',
|
||||
},
|
||||
{
|
||||
title: 'System disk io (bytes transferred)',
|
||||
yAxisUnit: 'bytes',
|
||||
title: 'System disk IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#system-disk-io-bytes',
|
||||
description:
|
||||
'Disk throughput in bytes/s per device and direction, tracking heavy file I/O or database flushes.',
|
||||
},
|
||||
{
|
||||
title: 'System disk operations/s',
|
||||
yAxisUnit: 'short',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/host-monitoring/#system-disk-operationss',
|
||||
description:
|
||||
'Rate of completed read and write operations per device; pair with disk io bytes to size each operation.',
|
||||
},
|
||||
{
|
||||
title: 'Queue size',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#queue-size',
|
||||
description:
|
||||
'Maximum disk request-queue depth per device; sustained high depth means the storage layer is saturated.',
|
||||
},
|
||||
{
|
||||
title: 'System disk operation time/s',
|
||||
yAxisUnit: 's',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/host-monitoring/#system-disk-operation-times',
|
||||
description:
|
||||
'Rate of cumulative disk-busy time per device and direction; values near 1s/s mean the device is saturated.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
.overview-container {
|
||||
.data-viewer {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.pretty-view__search-wrapper {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.pretty-view__search-input {
|
||||
background: var(--l2-background) !important;
|
||||
}
|
||||
|
||||
.log-body-value {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tag {
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
|
||||
@@ -13,21 +13,35 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
|
||||
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
|
||||
import TableView from './TableView';
|
||||
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
|
||||
import {
|
||||
aggregateAttributesResourcesToObject,
|
||||
buildPrettyViewData,
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
removeEscapeCharacters,
|
||||
} from './utils';
|
||||
|
||||
import './Overview.styles.scss';
|
||||
|
||||
// Skip body sanitization above this size. sanitization is expensive and fails
|
||||
// for large bodies
|
||||
const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
|
||||
|
||||
interface OverviewProps {
|
||||
logData: ILog;
|
||||
isListViewPanel?: boolean;
|
||||
selectedOptions: OptionsQuery;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
type Props = OverviewProps &
|
||||
@@ -42,6 +56,7 @@ function Overview({
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
@@ -51,6 +66,55 @@ function Overview({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { actions, visibleActions } = useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel,
|
||||
onApplyLogFilter,
|
||||
});
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
if (isLogDetailsV2) {
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
return (
|
||||
<div className="overview-container">
|
||||
<DataViewer
|
||||
data={prettyData}
|
||||
drawerKey="logs-details"
|
||||
fontSize={13}
|
||||
prettyViewProps={{
|
||||
actions,
|
||||
visibleActions,
|
||||
renderLeafValue: (value, keyPath): ReactNode | undefined => {
|
||||
// Sanitize (unescape + ANSI→color) string values under `body`.
|
||||
// Skip huge ones (render raw, still safe) to avoid the sanitize
|
||||
// choke;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
keyPath[keyPath.length - 1] !== 'body' ||
|
||||
value.length > MAX_BODY_SANITIZE_CHARS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="log-body-value"
|
||||
// Safe: getSanitizedLogBody runs the value through dompurify.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
jsonString={JSON.stringify(raw, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const options: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
|
||||
13
frontend/src/container/LogDetailedView/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export enum LogAttributeBucket {
|
||||
ATTRIBUTES = 'attributes',
|
||||
RESOURCES = 'resource',
|
||||
SCOPE = 'scope',
|
||||
}
|
||||
|
||||
export enum LogDetailsAction {
|
||||
COPY = 'copy',
|
||||
FILTER_IN = 'filter-in',
|
||||
FILTER_OUT = 'filter-out',
|
||||
GROUP_BY = 'group-by',
|
||||
REPLACE_FILTER = 'replace-filter',
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import {
|
||||
FieldContext,
|
||||
PrettyViewAction,
|
||||
VisibleActionsConfig,
|
||||
} from 'periscope/components/PrettyView/PrettyView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { LogDetailsAction } from '../constants';
|
||||
import {
|
||||
buildLogFilterTarget,
|
||||
getFilterQueryData,
|
||||
getGroupByQueryData,
|
||||
getReplaceFilterQueryData,
|
||||
} from '../logAttributeActions.utils';
|
||||
|
||||
interface UseLogAttributeActionsParams {
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
isListViewPanel?: boolean;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
interface UseLogAttributeActionsResult {
|
||||
actions: PrettyViewAction[];
|
||||
visibleActions: VisibleActionsConfig;
|
||||
}
|
||||
|
||||
const COPY_ONLY_ACTIONS = [LogDetailsAction.COPY];
|
||||
const ALL_LEAF_ACTIONS = [
|
||||
LogDetailsAction.COPY,
|
||||
LogDetailsAction.FILTER_IN,
|
||||
LogDetailsAction.FILTER_OUT,
|
||||
LogDetailsAction.GROUP_BY,
|
||||
LogDetailsAction.REPLACE_FILTER,
|
||||
];
|
||||
|
||||
/**
|
||||
* PrettyView filter/group-by/replace actions for the log-details drawer (keys mapped via
|
||||
* buildLogFilterTarget). Also owns `visibleActions` (leaf/nested + list-panel copy-only).
|
||||
*/
|
||||
export function useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel = false,
|
||||
onApplyLogFilter,
|
||||
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { featureFlags } = useAppContext();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
const isOldExplorerOrLive =
|
||||
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
const target = buildLogFilterTarget(
|
||||
context.fieldKeyPath,
|
||||
context.fieldValue,
|
||||
isBodyJsonQueryEnabled,
|
||||
);
|
||||
const operator = isFilterIn
|
||||
? target.filterInOperator
|
||||
: target.filterOutOperator;
|
||||
|
||||
// Non-explorer surfaces (infra monitoring, etc.) apply a ready v5
|
||||
// expression fragment to their own query.
|
||||
if (onApplyLogFilter) {
|
||||
const base = {
|
||||
filters: { items: [], op: 'AND' },
|
||||
} as unknown as IBuilderQuery;
|
||||
const nextFilters = getFilterQueryData(
|
||||
base,
|
||||
target,
|
||||
context.fieldValue,
|
||||
operator,
|
||||
).filters ?? { items: [], op: 'AND' };
|
||||
const { expression } = convertFiltersToExpression(nextFilters);
|
||||
if (expression) {
|
||||
onApplyLogFilter(expression);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) =>
|
||||
index === 0
|
||||
? getFilterQueryData(item, target, context.fieldValue, operator)
|
||||
: item,
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
},
|
||||
[
|
||||
stagedQuery,
|
||||
isBodyJsonQueryEnabled,
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
],
|
||||
);
|
||||
|
||||
const groupBy = useCallback(
|
||||
(context: FieldContext): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const target = buildLogFilterTarget(
|
||||
context.fieldKeyPath,
|
||||
context.fieldValue,
|
||||
isBodyJsonQueryEnabled,
|
||||
);
|
||||
if (!target.groupBySupported || !target.groupByKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => (index === 0 ? getGroupByQueryData(item, target) : item),
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
},
|
||||
[
|
||||
stagedQuery,
|
||||
isBodyJsonQueryEnabled,
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
|
||||
const replaceFilter = useCallback(
|
||||
(context: FieldContext): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const target = buildLogFilterTarget(
|
||||
context.fieldKeyPath,
|
||||
context.fieldValue,
|
||||
isBodyJsonQueryEnabled,
|
||||
);
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) =>
|
||||
index === 0
|
||||
? getReplaceFilterQueryData(item, target, context.fieldValue)
|
||||
: item,
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
},
|
||||
[
|
||||
stagedQuery,
|
||||
isBodyJsonQueryEnabled,
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
|
||||
const actions: PrettyViewAction[] = useMemo(() => {
|
||||
const isRestricted = (fieldKeyPath: (string | number)[]): boolean =>
|
||||
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.isRestricted;
|
||||
|
||||
// The using surface must provide an apply path.
|
||||
const canApplyFilter = !!handleChangeSelectedView || !!onApplyLogFilter;
|
||||
|
||||
return [
|
||||
{
|
||||
key: LogDetailsAction.FILTER_IN,
|
||||
label: 'Filter for value',
|
||||
icon: <CirclePlus size={12} />,
|
||||
onClick: (context): void => filterFor(context, true),
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!canApplyFilter || isRestricted(fieldKeyPath),
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.FILTER_OUT,
|
||||
label: 'Filter out value',
|
||||
icon: <CircleMinus size={12} />,
|
||||
onClick: (context): void => filterFor(context, false),
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!canApplyFilter || isRestricted(fieldKeyPath),
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.GROUP_BY,
|
||||
label: 'Group by field',
|
||||
icon: <Layers size={12} />,
|
||||
onClick: groupBy,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView ||
|
||||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.groupBySupported ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.REPLACE_FILTER,
|
||||
label: 'Replace filters with this value',
|
||||
icon: <RefreshCw size={12} />,
|
||||
onClick: replaceFilter,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView ||
|
||||
isRestricted(fieldKeyPath) ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
filterFor,
|
||||
groupBy,
|
||||
replaceFilter,
|
||||
isBodyJsonQueryEnabled,
|
||||
isOldExplorerOrLive,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
]);
|
||||
|
||||
const visibleActions = useMemo<VisibleActionsConfig>(
|
||||
() => ({
|
||||
leaf: isListViewPanel ? COPY_ONLY_ACTIONS : ALL_LEAF_ACTIONS,
|
||||
nested: COPY_ONLY_ACTIONS,
|
||||
}),
|
||||
[isListViewPanel],
|
||||
);
|
||||
|
||||
return { actions, visibleActions };
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
|
||||
import {
|
||||
buildLogFilterTarget,
|
||||
toTypedFilterValue,
|
||||
} from './logAttributeActions.utils';
|
||||
|
||||
describe('buildLogFilterTarget', () => {
|
||||
describe('attributes / resources / scope / top-level scalars', () => {
|
||||
it('maps a top-level scalar to its bare key with =/!=, groupable', () => {
|
||||
const t = buildLogFilterTarget(['severity_text'], 'ERROR', true);
|
||||
expect(t).toMatchObject({
|
||||
fieldKey: 'severity_text',
|
||||
filterInOperator: '=',
|
||||
filterOutOperator: '!=',
|
||||
groupBySupported: true,
|
||||
groupByKey: 'severity_text',
|
||||
isRestricted: false,
|
||||
});
|
||||
expect(t.metricsType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips the `attributes` root to a bare dotted key + Tag type', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['attributes', 'http.method'], 'GET', true),
|
||||
).toMatchObject({
|
||||
fieldKey: 'http.method',
|
||||
filterInOperator: '=',
|
||||
metricsType: MetricsType.Tag,
|
||||
groupBySupported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps `resources` with Resource type', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['resource', 'service.name'], 'api', true),
|
||||
).toMatchObject({
|
||||
fieldKey: 'service.name',
|
||||
metricsType: MetricsType.Resource,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps `scope` with Scope type', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['scope', 'name'], 'my-scope', true),
|
||||
).toMatchObject({ fieldKey: 'name', metricsType: MetricsType.Scope });
|
||||
});
|
||||
|
||||
it('offers group-by for attributes now', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['attributes', 'k'], 'v', true).groupBySupported,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested attribute values (parsed JSON)', () => {
|
||||
it('marks a sub-field of a parsed attribute copy-only (restricted, no group-by)', () => {
|
||||
const t = buildLogFilterTarget(['attributes', 'payload', 'x'], 1, true);
|
||||
expect(t.isRestricted).toBe(true);
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
expect(t.groupByKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a top-level attribute (depth 2) filterable', () => {
|
||||
const t = buildLogFilterTarget(['attributes', 'payload'], 'v', true);
|
||||
expect(t.isRestricted).toBe(false);
|
||||
expect(t.groupBySupported).toBe(true);
|
||||
});
|
||||
|
||||
it('does not restrict nested resource/scope values', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['resource', 'k8s', 'pod'], 'p', true).isRestricted,
|
||||
).toBe(false);
|
||||
expect(
|
||||
buildLogFilterTarget(['scope', 'a', 'b'], 'v', true).isRestricted,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted fields (timestamp / id)', () => {
|
||||
it.each(['timestamp', 'id'])(
|
||||
'marks %s restricted with no group-by',
|
||||
(key) => {
|
||||
const t = buildLogFilterTarget([key], 'v', true);
|
||||
expect(t.isRestricted).toBe(true);
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
expect(t.groupByKey).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('group-by-restricted fields (trace_id)', () => {
|
||||
it('allows filtering but not group-by on top-level trace_id', () => {
|
||||
const t = buildLogFilterTarget(['trace_id'], 'abc123', true);
|
||||
expect(t.isRestricted).toBe(false);
|
||||
expect(t.filterInOperator).toBe('=');
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
expect(t.groupByKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['resource', ['resource', 'trace_id']],
|
||||
['attributes', ['attributes', 'trace_id']],
|
||||
])(
|
||||
'blocks group-by on a %s field named trace_id, keeping filter',
|
||||
(_bucket, path) => {
|
||||
const t = buildLogFilterTarget(path as string[], 'abc123', true);
|
||||
expect(t.isRestricted).toBe(false);
|
||||
expect(t.filterInOperator).toBe('=');
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
expect(t.groupByKey).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('body scalars', () => {
|
||||
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
|
||||
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);
|
||||
expect(t).toMatchObject({
|
||||
fieldKey: 'body.message',
|
||||
filterInOperator: '=',
|
||||
filterOutOperator: '!=',
|
||||
groupBySupported: true,
|
||||
groupByKey: 'body.message',
|
||||
isRestricted: false,
|
||||
});
|
||||
expect(t.dataType).toBeDefined();
|
||||
expect(t.metricsType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps a nested body scalar to a dotted body key', () => {
|
||||
expect(buildLogFilterTarget(['body', 'a', 'b'], 'x', true)).toMatchObject({
|
||||
fieldKey: 'body.a.b',
|
||||
groupBySupported: true,
|
||||
groupByKey: 'body.a.b',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not offer group-by when USE_JSON_BODY is off', () => {
|
||||
const t = buildLogFilterTarget(['body', 'message'], 'hello', false);
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
expect(t.groupByKey).toBeUndefined();
|
||||
expect(t.fieldKey).toBe('body.message');
|
||||
});
|
||||
|
||||
it('filters the whole `body` field when body is an unparsed string leaf', () => {
|
||||
expect(buildLogFilterTarget(['body'], 'raw text', true)).toMatchObject({
|
||||
fieldKey: 'body',
|
||||
filterInOperator: '=',
|
||||
groupBySupported: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('restricts a body leaf named `timestamp` (no filter / group-by)', () => {
|
||||
const t = buildLogFilterTarget(['body', 'timestamp'], '2026-01-01', true);
|
||||
expect(t.fieldKey).toBe('body.timestamp');
|
||||
expect(t.isRestricted).toBe(true);
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
expect(t.groupByKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restricts a nested body leaf named `timestamp`', () => {
|
||||
const t = buildLogFilterTarget(['body', 'obj', 'timestamp'], 'x', true);
|
||||
expect(t.isRestricted).toBe(true);
|
||||
expect(t.groupBySupported).toBe(false);
|
||||
});
|
||||
|
||||
it('restricts a body leaf named `id` (uses RESTRICTED_SELECTED_FIELDS)', () => {
|
||||
expect(buildLogFilterTarget(['body', 'id'], 'abc', true).isRestricted).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restrict an ordinary body leaf', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['body', 'message'], 'hello', true).isRestricted,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('body arrays', () => {
|
||||
it('uses has()/!has() on the array key for a primitive array element', () => {
|
||||
const t = buildLogFilterTarget(['body', 'tags', 0], 'urgent', true);
|
||||
expect(t).toMatchObject({
|
||||
fieldKey: 'body.tags',
|
||||
filterInOperator: 'has',
|
||||
groupBySupported: false,
|
||||
});
|
||||
expect(t.filterOutOperator).toContain('has');
|
||||
expect(t.filterOutOperator).not.toBe('has');
|
||||
});
|
||||
|
||||
it('collapses deep array-element paths to a []-marked has() key', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(
|
||||
['body', 'config', 'features', 1, 'items', 0, 'variants', 2],
|
||||
'ballpen',
|
||||
true,
|
||||
),
|
||||
).toMatchObject({
|
||||
fieldKey: 'body.config.features[].items[].variants',
|
||||
filterInOperator: 'has',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a field nested inside an array element with =/!= and a []-marked key, no group-by', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', true),
|
||||
).toMatchObject({
|
||||
fieldKey: 'body.items[].sku',
|
||||
filterInOperator: '=',
|
||||
filterOutOperator: '!=',
|
||||
groupBySupported: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the [*] string-body marker for an array element when USE_JSON_BODY is off', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['body', 'tags', 0], 'urgent', false),
|
||||
).toMatchObject({ fieldKey: 'body.tags[*]', filterInOperator: 'has' });
|
||||
});
|
||||
|
||||
it('uses [*] for a field nested inside an array element when USE_JSON_BODY is off', () => {
|
||||
expect(
|
||||
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', false),
|
||||
).toMatchObject({ fieldKey: 'body.items[*].sku', filterInOperator: '=' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTypedFilterValue', () => {
|
||||
const run = (value: unknown): unknown => toTypedFilterValue(value);
|
||||
|
||||
it('keeps numbers/booleans as their JS type (so the expression stays unquoted)', () => {
|
||||
expect(run(848)).toBe(848);
|
||||
expect(typeof run(848)).toBe('number');
|
||||
expect(run(1.1)).toBe(1.1);
|
||||
expect(run(true)).toBe(true);
|
||||
expect(typeof run(true)).toBe('boolean');
|
||||
});
|
||||
|
||||
it('passes strings through unchanged (no numeric inference)', () => {
|
||||
expect(run('unknown_service')).toBe('unknown_service');
|
||||
expect(typeof run('12345')).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
negateOperator,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
RESTRICTED_GROUP_BY_FIELDS,
|
||||
RESTRICTED_SELECTED_FIELDS,
|
||||
} from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { LogAttributeBucket } from './constants';
|
||||
import { generateFieldKeyForArray, getDataTypes } from './utils';
|
||||
|
||||
export const toTypedFilterValue = (value: unknown): string =>
|
||||
typeof value === 'number' || typeof value === 'boolean'
|
||||
? (value as unknown as string)
|
||||
: String(value);
|
||||
|
||||
export interface LogFilterTarget {
|
||||
fieldKey: string;
|
||||
filterInOperator: string;
|
||||
filterOutOperator: string;
|
||||
dataType?: DataTypes;
|
||||
metricsType?: MetricsType;
|
||||
groupBySupported: boolean;
|
||||
groupByKey?: string;
|
||||
isRestricted: boolean;
|
||||
}
|
||||
|
||||
// Collapse a body forward path into the query-builder key segment; array indices become
|
||||
// `[]` (json body on) or `[*]` (string body off — a distinct operator/search path).
|
||||
// ['items', 2, 'sku'] -> 'items[].sku' (json on) / 'items[*].sku' (off)
|
||||
// ['tags', 0] -> 'tags[]' (json on) / 'tags[*]' (off)
|
||||
const collapseBodyPath = (
|
||||
subpath: (string | number)[],
|
||||
isBodyJsonQueryEnabled: boolean,
|
||||
): string => {
|
||||
const arrayMarker = isBodyJsonQueryEnabled ? '[]' : '[*]';
|
||||
let out = '';
|
||||
subpath.forEach((seg) => {
|
||||
if (typeof seg === 'number') {
|
||||
out += arrayMarker;
|
||||
} else {
|
||||
out += out ? `.${seg}` : seg;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const metricsTypeForRoot = (root: string | number): MetricsType | undefined => {
|
||||
if (root === LogAttributeBucket.ATTRIBUTES) {
|
||||
return MetricsType.Tag;
|
||||
}
|
||||
if (root === LogAttributeBucket.RESOURCES) {
|
||||
return MetricsType.Resource;
|
||||
}
|
||||
if (root === LogAttributeBucket.SCOPE) {
|
||||
return MetricsType.Scope;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a PrettyView leaf (forward keyPath) to its query-builder filter/group-by target:
|
||||
* scalars → `=`/`!=`, body array elements → `has`/`!has`; group-by gated by
|
||||
* `groupBySupported`. Attribute/resource/scope carry a `metricsType` (Tag/Resource/Scope).
|
||||
*/
|
||||
export const buildLogFilterTarget = (
|
||||
fieldKeyPath: (string | number)[],
|
||||
value: unknown,
|
||||
isBodyJsonQueryEnabled: boolean,
|
||||
): LogFilterTarget => {
|
||||
const root = fieldKeyPath[0];
|
||||
|
||||
// Attributes / resources / scope / top-level scalars: bare dotted key, =/!=.
|
||||
if (root !== 'body') {
|
||||
const fieldKey =
|
||||
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
|
||||
// Temporarily removing filter/group-by support for nested attributes.
|
||||
// This will be removed once backend starts to support these actions.
|
||||
const isNestedAttributeValue =
|
||||
root === LogAttributeBucket.ATTRIBUTES && fieldKeyPath.length > 2;
|
||||
|
||||
const isRestricted =
|
||||
RESTRICTED_SELECTED_FIELDS.includes(fieldKey) || isNestedAttributeValue;
|
||||
|
||||
const groupBySupported =
|
||||
!isRestricted && !RESTRICTED_GROUP_BY_FIELDS.includes(fieldKey);
|
||||
return {
|
||||
fieldKey,
|
||||
filterInOperator: OPERATORS['='],
|
||||
filterOutOperator: OPERATORS['!='],
|
||||
dataType: getDataTypes(value),
|
||||
metricsType: metricsTypeForRoot(root),
|
||||
groupBySupported,
|
||||
groupByKey: groupBySupported ? fieldKey : undefined,
|
||||
isRestricted,
|
||||
};
|
||||
}
|
||||
|
||||
const subpath = fieldKeyPath.slice(1);
|
||||
|
||||
// Whole-body leaf (unparsed string body): filter on the `body` field itself.
|
||||
if (subpath.length === 0) {
|
||||
return {
|
||||
fieldKey: 'body',
|
||||
filterInOperator: OPERATORS['='],
|
||||
filterOutOperator: OPERATORS['!='],
|
||||
dataType: getDataTypes(value),
|
||||
groupBySupported: false,
|
||||
isRestricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const collapsed = collapseBodyPath(subpath, isBodyJsonQueryEnabled);
|
||||
const isArrayElement = typeof subpath[subpath.length - 1] === 'number';
|
||||
|
||||
if (isArrayElement) {
|
||||
// has(body.<array>, value): generateFieldKeyForArray strips the trailing value
|
||||
// segment + `[]` exactly as the old BodyTitleRenderer.filterHandler did.
|
||||
const fieldKey = generateFieldKeyForArray(
|
||||
`${collapsed}.${String(value)}`,
|
||||
getDataTypes(value),
|
||||
isBodyJsonQueryEnabled,
|
||||
);
|
||||
return {
|
||||
fieldKey,
|
||||
filterInOperator: QUERY_BUILDER_FUNCTIONS.HAS,
|
||||
filterOutOperator: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
|
||||
dataType: getDataTypes([value]),
|
||||
groupBySupported: false,
|
||||
isRestricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const fieldKey = `body.${collapsed}`;
|
||||
// Restrict body leaves whose own key is a restricted field (e.g. a JSON body's own
|
||||
// `timestamp`/`id`/`date`) — hides filter / group-by, same as top-level fields.
|
||||
const leafKey = String(subpath[subpath.length - 1]);
|
||||
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(leafKey);
|
||||
// Group by only for plain body scalars (no array anywhere in the path) with json
|
||||
// body on — mirrors isGroupBySupported in the old BodyTitleRenderer.
|
||||
const groupBySupported =
|
||||
isBodyJsonQueryEnabled && !collapsed.includes('[]') && !isRestricted;
|
||||
return {
|
||||
fieldKey,
|
||||
filterInOperator: OPERATORS['='],
|
||||
filterOutOperator: OPERATORS['!='],
|
||||
dataType: getDataTypes(value),
|
||||
groupBySupported,
|
||||
groupByKey: groupBySupported ? fieldKey : undefined,
|
||||
isRestricted,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDataType = (
|
||||
dataType: DataTypes | undefined,
|
||||
): DataTypes | undefined =>
|
||||
dataType && Object.values(DataTypes).includes(dataType) ? dataType : undefined;
|
||||
|
||||
// Append a filter item (filter-in / filter-out) to a query-data item. The autocomplete
|
||||
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
|
||||
export const getFilterQueryData = (
|
||||
item: IBuilderQuery,
|
||||
target: LogFilterTarget,
|
||||
value: unknown,
|
||||
operator: string,
|
||||
): IBuilderQuery => {
|
||||
const filterKey = chooseAutocompleteFromCustomValue(
|
||||
[],
|
||||
target.fieldKey,
|
||||
target.dataType,
|
||||
target.metricsType,
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
filters: {
|
||||
items: [
|
||||
...(item.filters?.items || []),
|
||||
{
|
||||
id: uuid(),
|
||||
key: filterKey,
|
||||
op: getOperatorValue(operator),
|
||||
value: toTypedFilterValue(value),
|
||||
},
|
||||
],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Append a group-by. Caller guards groupBySupported / groupByKey.
|
||||
export const getGroupByQueryData = (
|
||||
item: IBuilderQuery,
|
||||
target: LogFilterTarget,
|
||||
): IBuilderQuery => {
|
||||
const newGroupByItem: BaseAutocompleteData = {
|
||||
key: target.groupByKey || '',
|
||||
type: target.metricsType || '',
|
||||
dataType: normalizeDataType(target.dataType),
|
||||
};
|
||||
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
|
||||
};
|
||||
|
||||
// Replace all filters with a single IN filter on this value.
|
||||
export const getReplaceFilterQueryData = (
|
||||
item: IBuilderQuery,
|
||||
target: LogFilterTarget,
|
||||
value: unknown,
|
||||
): IBuilderQuery => {
|
||||
const newFilterItem: BaseAutocompleteData = {
|
||||
key: target.fieldKey,
|
||||
type: target.metricsType || '',
|
||||
dataType: normalizeDataType(target.dataType),
|
||||
};
|
||||
return {
|
||||
...item,
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '',
|
||||
key: newFilterItem,
|
||||
op: QUERY_BUILDER_OPERATORS.IN,
|
||||
value: [toTypedFilterValue(value)],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: '' },
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,121 @@
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import {
|
||||
aggregateAttributesResourcesToObject,
|
||||
buildPrettyViewData,
|
||||
flattenObject,
|
||||
getDataTypes,
|
||||
getSanitizedLogBody,
|
||||
parseJsonStringValue,
|
||||
recursiveParseJSON,
|
||||
} from './utils';
|
||||
|
||||
describe('parseJsonStringValue', () => {
|
||||
it('parses a JSON-object string into an object', () => {
|
||||
expect(parseJsonStringValue('{"a":1,"b":{"c":2}}')).toStrictEqual({
|
||||
a: 1,
|
||||
b: { c: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a JSON-array string into an array', () => {
|
||||
expect(parseJsonStringValue('[1,2,3]')).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns a plain (non-JSON) string unchanged', () => {
|
||||
expect(parseJsonStringValue('plain log line')).toBe('plain log line');
|
||||
});
|
||||
|
||||
it('returns a string that is not object/array-looking unchanged', () => {
|
||||
expect(parseJsonStringValue('42')).toBe('42');
|
||||
});
|
||||
|
||||
it('returns an invalid JSON string unchanged', () => {
|
||||
expect(parseJsonStringValue('{not valid}')).toBe('{not valid}');
|
||||
});
|
||||
|
||||
it('returns an already-object value unchanged (same reference)', () => {
|
||||
const value = { message: 'hi', a: 1 };
|
||||
expect(parseJsonStringValue(value)).toBe(value);
|
||||
});
|
||||
|
||||
it('leaves a value larger than the 128KB parse guard as a string', () => {
|
||||
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
|
||||
expect(parseJsonStringValue(huge)).toBe(huge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPrettyViewData', () => {
|
||||
const baseRaw = {
|
||||
id: 'log-1',
|
||||
timestamp: 1234,
|
||||
body: 'hello',
|
||||
attributes: {},
|
||||
resource: {},
|
||||
scope: {},
|
||||
} as any;
|
||||
|
||||
it('parses a JSON-string body into a tree', () => {
|
||||
const result = buildPrettyViewData({ ...baseRaw, body: '{"a":1}' });
|
||||
expect(result.body).toStrictEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('parses attribute values that are JSON strings, leaves others as-is', () => {
|
||||
const result = buildPrettyViewData({
|
||||
...baseRaw,
|
||||
attributes: { payload: '{"x":1}', name: 'cart', count: 3 },
|
||||
});
|
||||
expect(result.attributes).toStrictEqual({
|
||||
payload: { x: 1 },
|
||||
name: 'cart',
|
||||
count: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops undefined fields so they do not render as empty rows', () => {
|
||||
const result = buildPrettyViewData({ ...baseRaw, trace_id: undefined });
|
||||
expect('trace_id' in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateAttributesResourcesToObject', () => {
|
||||
const mockLog = {
|
||||
id: 'log-1',
|
||||
timestamp: 1234,
|
||||
body: 'hello',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
attributes_string: { 'http.method': 'GET' },
|
||||
attributes_int: { retries: 3 },
|
||||
resources_string: { 'service.name': 'cart' },
|
||||
scope_string: { lib: 'otel' },
|
||||
} as unknown as ILog;
|
||||
|
||||
it('merges attributes_/resources_/scope_ maps and keeps scalars + body', () => {
|
||||
const result = aggregateAttributesResourcesToObject(mockLog);
|
||||
|
||||
expect(result.attributes).toStrictEqual({
|
||||
'http.method': 'GET',
|
||||
retries: 3,
|
||||
});
|
||||
expect(result.resource).toStrictEqual({ 'service.name': 'cart' });
|
||||
expect(result.scope).toStrictEqual({ lib: 'otel' });
|
||||
expect(result.body).toBe('hello');
|
||||
expect(result.id).toBe('log-1');
|
||||
expect(result.severity_text).toBe('INFO');
|
||||
});
|
||||
|
||||
it('does not parse a JSON-string body (leaves it raw)', () => {
|
||||
const result = aggregateAttributesResourcesToObject({
|
||||
...mockLog,
|
||||
body: '{"a":1}',
|
||||
} as unknown as ILog);
|
||||
|
||||
expect(result.body).toBe('{"a":1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recursiveParseJSON', () => {
|
||||
it('should return an empty object if the input is not valid JSON', () => {
|
||||
const result = recursiveParseJSON('not valid JSON');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import Convert from 'ansi-to-html';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
@@ -262,10 +263,11 @@ export const filterKeyForField = (field: string): string => {
|
||||
return fieldAttribs?.newField || field;
|
||||
};
|
||||
|
||||
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
|
||||
export const aggregateAttributesResourcesToObject = (
|
||||
logData: ILog,
|
||||
): ILogAggregateAttributesResources => {
|
||||
const outputJson: ILogAggregateAttributesResources = {
|
||||
body: logData.body,
|
||||
date: logData.date,
|
||||
id: logData.id,
|
||||
severityNumber: logData.severityNumber,
|
||||
severityText: logData.severityText,
|
||||
@@ -274,29 +276,94 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
|
||||
traceFlags: logData.traceFlags,
|
||||
traceId: logData.traceId,
|
||||
attributes: {},
|
||||
resources: {},
|
||||
resource: {},
|
||||
scope: {},
|
||||
severity_text: logData.severity_text,
|
||||
severity_number: logData.severity_number,
|
||||
};
|
||||
|
||||
Object.keys(logData).forEach((key) => {
|
||||
if (key === 'date') {
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('attributes_')) {
|
||||
outputJson.attributes = outputJson.attributes || {};
|
||||
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
|
||||
} else if (key.startsWith('resources_')) {
|
||||
outputJson.resources = outputJson.resources || {};
|
||||
Object.assign(outputJson.resources, logData[key as keyof ILog]);
|
||||
outputJson.resource = outputJson.resource || {};
|
||||
Object.assign(outputJson.resource, logData[key as keyof ILog]);
|
||||
} else if (key.startsWith('scope_string')) {
|
||||
outputJson.scope = outputJson.scope || {};
|
||||
Object.assign(outputJson.scope, logData[key as keyof ILog]);
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error dynamic top-level copy
|
||||
outputJson[key] = logData[key as keyof ILog];
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(outputJson, null, 2);
|
||||
// Show `timestamp` first and `id` last in the details view.
|
||||
const { timestamp, id, ...rest } = outputJson;
|
||||
return { timestamp, ...rest, id };
|
||||
};
|
||||
|
||||
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
|
||||
try {
|
||||
return JSON.stringify(aggregateAttributesResourcesToObject(logData), null, 2);
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_JSON_PARSE_BYTES = 128 * 1024;
|
||||
|
||||
// A JSON-encoded object/array string is parsed so DataViewer renders it as a tree
|
||||
// instead of one escaped string; non-JSON / plain-text values are returned unchanged.
|
||||
// Guarded against very large payloads.
|
||||
export const parseJsonStringValue = (value: unknown): unknown => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
if (!looksLikeJson || trimmed.length > MAX_JSON_PARSE_BYTES) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed !== null && typeof parsed === 'object' ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse each attribute value that's a stringified JSON string into an object
|
||||
// Non-JSON values are left unchanged.
|
||||
const parseAttributeJsonValues = (
|
||||
attributes: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const parsed: Record<string, unknown> = {};
|
||||
Object.keys(attributes).forEach((key) => {
|
||||
parsed[key] = parseJsonStringValue(attributes[key]);
|
||||
});
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const buildPrettyViewData = (
|
||||
raw: ILogAggregateAttributesResources,
|
||||
): Record<string, unknown> => {
|
||||
const prettyData: Record<string, unknown> = { ...raw };
|
||||
prettyData.body = parseJsonStringValue(raw.body);
|
||||
prettyData.attributes = parseAttributeJsonValues(raw.attributes);
|
||||
|
||||
// drop undefined fields so they don't render as empty rows
|
||||
Object.keys(prettyData).forEach((key) => {
|
||||
if (prettyData[key] === undefined) {
|
||||
delete prettyData[key];
|
||||
}
|
||||
});
|
||||
|
||||
return prettyData;
|
||||
};
|
||||
|
||||
const isFloat = (num: number): boolean => num % 1 !== 0;
|
||||
|
||||
@@ -2,6 +2,9 @@ import { blue, red } from '@ant-design/colors';
|
||||
|
||||
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
|
||||
|
||||
// Fields that can be filtered on but not grouped by in the log details view.
|
||||
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
|
||||
|
||||
export const ICON_STYLE = {
|
||||
PLUS: { color: blue[5] },
|
||||
CLOSE: { color: red[5] },
|
||||
|
||||
@@ -124,6 +124,9 @@ function Application(): JSX.Element {
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
}),
|
||||
// the time range is part of the key, so without this every window change blanks the
|
||||
// operations list and the widgets below are rebuilt with an empty `operation in []`
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
|
||||