Compare commits
55 Commits
issue-4293
...
chore/scaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b9f945dc | ||
|
|
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 |
1317
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)
|
||||
|
||||
96
frontend/.claude/skills/scaffold-feature/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: scaffold-feature
|
||||
description: Scaffold the co-located feature structure in frontend/src. Use when creating a new page, feature, view (tab), or component folder, when a feature needs a shell with tabs, or when moving existing code out of src/container into src/pages. Generates the full folder tree (components/hooks/store/types/utils/constants/__tests__/README) with one command.
|
||||
---
|
||||
|
||||
# Scaffold a feature
|
||||
|
||||
The frontend is moving to a co-located layout (Bulletproof React / FSD): everything a
|
||||
feature owns lives in the feature's folder. Read `references/layout.md` for the full
|
||||
target structure and the rules about what may live where.
|
||||
|
||||
**Never hand-create these folders.** Run the generator so every feature comes out
|
||||
identical, then fill it in.
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
pnpm scaffold page <Name> [options] # a page/feature under src/pages
|
||||
pnpm scaffold component <Name> [options] # a component folder
|
||||
```
|
||||
|
||||
| Option | Applies to | Effect |
|
||||
| --- | --- | --- |
|
||||
| `--views A,B,C` | `page` | Makes the page a shell with tab switching and generates one view folder per name. |
|
||||
| `--parent <path>` | `component` | Parent, relative to `src` (default `components`). A feature path like `pages/Traces/Explorer` nests the component under that feature's `components/`. |
|
||||
| `--full` | `component` | Also adds `components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `README.md` for a component that owns children. |
|
||||
| `--no-tests` | both | Skips `__tests__/`. |
|
||||
| `--dry-run` | both | Prints what would be written, writes nothing. |
|
||||
| `--force` | both | Overwrites files that already exist (off by default; existing entries are reported as skipped). |
|
||||
|
||||
Folder names keep the casing you type, with the first letter forced up, so
|
||||
`LLMObservability` stays `LLMObservability` rather than being re-cased. Separated names
|
||||
collapse to PascalCase: `api-monitoring` and `api monitoring` both give
|
||||
`pages/ApiMonitoring`. Test ids, headings, tab paths and constants are all derived from
|
||||
that folder name — `TracesFunnels` gives `traces-funnels-page`, `Traces Funnels` and
|
||||
`TRACES_FUNNELS_TABS`.
|
||||
|
||||
## What you get
|
||||
|
||||
```
|
||||
pages/ApiMonitoring/
|
||||
index.tsx # the page component
|
||||
ApiMonitoring.module.scss
|
||||
components/ hooks/ store/ # empty, ready for the first file
|
||||
types.ts utils.ts constants.ts
|
||||
__tests__/ApiMonitoring.test.tsx
|
||||
README.md
|
||||
```
|
||||
|
||||
With `--views`, the root becomes a `RouteTab` shell (`index.tsx` + `constants.ts` with the
|
||||
tab definitions) and each view gets the tree above.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
pnpm scaffold page ApiMonitoring # leaf page, no shell
|
||||
pnpm scaffold page Traces --views Explorer,Funnels,Views # shell + 3 views
|
||||
pnpm scaffold page Traces/Explorer # one more view under an existing shell
|
||||
pnpm scaffold component DataTable # global, src/components/DataTable
|
||||
pnpm scaffold component QueryBar --parent pages/Traces/Explorer # feature-local component
|
||||
```
|
||||
|
||||
## After generating
|
||||
|
||||
1. **Wire the route** (pages only) — the generator does not touch shared files:
|
||||
- add the path to `src/constants/routes.ts`
|
||||
- add a `Loadable` lazy import to `src/AppRoutes/pageComponents.ts`
|
||||
- add the entry to `src/AppRoutes/routes.ts`
|
||||
- for a shell, replace the local `BASE_PATH` strings in `constants.ts` with those `ROUTES` entries
|
||||
2. **Delete the placeholders you don't need** — empty `types.ts` / `utils.ts` /
|
||||
`constants.ts`, and any of `components/`, `hooks/`, `store/` the feature won't use.
|
||||
Those three folders are created empty; git only picks them up once they hold a file.
|
||||
3. **Fill the README** — the generated file has the prompts; a feature folder without a
|
||||
filled-in README is not done.
|
||||
4. **Follow the repo rules while filling it in**: `@signozhq/ui` + `@signozhq/icons` only,
|
||||
CSS Modules (`docs/css-modules-guide.md`), React Query for server state (prefer
|
||||
`api/generated` hooks), nuqs for URL state, Zustand for client state, `data-testid` on
|
||||
every interactive element.
|
||||
5. **Verify** before reporting done:
|
||||
```bash
|
||||
pnpm tsgo --noEmit
|
||||
pnpm oxlint src/pages/<Feature>
|
||||
pnpm jest src/pages/<Feature>
|
||||
```
|
||||
|
||||
## Editing the templates
|
||||
|
||||
Templates live in `templates/` — `feature/`, `shell/`, `component/` and
|
||||
`component-extras/` (the `--full` additions). Every template file ends in `.tmpl`, which
|
||||
keeps TypeScript, lint and your editor from reading them as source; the generator strips
|
||||
that suffix on the way out, so `index.tsx.tmpl` becomes `index.tsx`. Tokens are
|
||||
substituted in both file names and contents: `__Pascal__`, `__kebab__`, `__camel__`,
|
||||
`__CONST__`, `__Title__`. The shell's `constants.ts` additionally takes
|
||||
`__VIEW_IMPORTS__` and `__TAB_ENTRIES__`, which the generator builds from `--views`. The
|
||||
empty folders come from `FEATURE_DIRS` in `scaffold.mjs`. Change these, not the generated
|
||||
output, when the team's conventions move.
|
||||
102
frontend/.claude/skills/scaffold-feature/references/layout.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Frontend layout
|
||||
|
||||
Target structure for `frontend/src`. Inspired by Bulletproof React and Feature-Sliced
|
||||
Design: a feature owns its components, hooks, state, types and tests, and nothing outside
|
||||
the feature folder reaches into it.
|
||||
|
||||
```
|
||||
src/
|
||||
app/ # bootstrap: routing, global styles/theme
|
||||
pages/
|
||||
Traces/ # has a shell
|
||||
index.tsx # shell — tab switching only
|
||||
constants.ts # tab definitions
|
||||
Explorer/ # a view
|
||||
index.tsx # view entry — composition, no business logic
|
||||
components/
|
||||
QueryBar/ # same shape as a global component, nests further as needed
|
||||
QueryBar.tsx
|
||||
QueryBar.module.scss
|
||||
components/
|
||||
hooks/
|
||||
__tests__/
|
||||
hooks/ # feature hooks + React Query wrappers over api/generated
|
||||
store/ # Zustand stores for feature-local client state
|
||||
types.ts
|
||||
utils.ts
|
||||
constants.ts
|
||||
__tests__/
|
||||
README.md
|
||||
Funnels/
|
||||
Views/
|
||||
ApiMonitoring/ # no shell — same shape, one level up
|
||||
index.tsx
|
||||
components/
|
||||
hooks/
|
||||
store/
|
||||
types.ts
|
||||
utils.ts
|
||||
constants.ts
|
||||
__tests__/
|
||||
README.md
|
||||
components/ # cross-feature components, same internal shape as above
|
||||
DataTable/
|
||||
DataTable.tsx
|
||||
DataTable.module.scss
|
||||
components/
|
||||
hooks/
|
||||
store/
|
||||
types.ts
|
||||
utils.ts
|
||||
constants.ts
|
||||
__tests__/
|
||||
README.md
|
||||
lib/
|
||||
utils/
|
||||
types/
|
||||
constants/
|
||||
store/ # app-wide client state only
|
||||
i18n/
|
||||
api/
|
||||
generated/ # Orval output — never edited by hand
|
||||
client/ # axios instances, interceptors, error handlers
|
||||
index.tsx
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Folder names are PascalCase**, spelled the way the feature is spelled in the product
|
||||
(`ApiMonitoring`, `LLMObservability`). This holds for shells, views and components alike.
|
||||
- **A page folder is the unit of ownership.** Anything used by exactly one feature lives
|
||||
inside it, however deeply nested. Promote to `src/components` / `src/utils` / `src/hooks`
|
||||
only when a second feature needs it.
|
||||
- **`index.tsx` is the entry**, and it composes. Business logic goes to `hooks/`, data
|
||||
shaping to `utils.ts`, state to `store/`.
|
||||
- **Nested components repeat the same shape.** A component folder may hold its own
|
||||
`components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `__tests__/`.
|
||||
Nest as deep as ownership actually goes; don't flatten a component that owns children.
|
||||
- **Shell vs no shell.** A page with tabs gets a shell `index.tsx` whose only job is tab
|
||||
switching, plus one folder per view. A page without tabs is just the feature folder.
|
||||
- **Tests.** Feature-root tests in `__tests__/`; a component's tests next to the component
|
||||
(its own `__tests__/`). Never reach across features in a test.
|
||||
- **No barrel files.** A page's `index.tsx` is the route entry (a component), not a
|
||||
re-export hub. Import components by their own path.
|
||||
- **File size.** Split past ~300 LOC: extract components, and behaviour into
|
||||
`use<Component>Callbacks`-style hooks. More than ~3 type declarations in a file means a
|
||||
`types.ts`, and more than ~3 in `types.ts` means a `types/` folder.
|
||||
- **Styling.** CSS Modules (`<Name>.module.scss`) next to the component — see
|
||||
`docs/css-modules-guide.md`. Semantic tokens only.
|
||||
- **State.** Server → React Query (prefer `api/generated` hooks); URL → nuqs; client →
|
||||
Zustand, one store per file, always with a selector. No Redux or Context for new code.
|
||||
|
||||
## Migrating existing code
|
||||
|
||||
Most feature code still lives in `src/container` and `src/modules`, with a thin wrapper in
|
||||
`src/pages`. When touching one of those features:
|
||||
|
||||
1. Scaffold the target with `pnpm scaffold page <Name>` (see `../SKILL.md`).
|
||||
2. Move files in, one concern per commit — components, then hooks, then state.
|
||||
3. Update importers; keep `src/container/<Feature>` deleted, not re-exported. A shim
|
||||
directory is how the old layout survives.
|
||||
4. Do the dead-code pass first: unused props, exports, imports and debug logs go before the
|
||||
move, in their own commit.
|
||||
379
frontend/.claude/skills/scaffold-feature/scaffold.mjs
Executable file
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SKILL_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const TEMPLATES = join(SKILL_DIR, 'templates');
|
||||
const FRONTEND = resolve(SKILL_DIR, '..', '..', '..');
|
||||
const SRC = join(FRONTEND, 'src');
|
||||
|
||||
// Created empty, so the folder exists before it has a file to justify it.
|
||||
const FEATURE_DIRS = ['components', 'hooks', 'store'];
|
||||
|
||||
const USAGE = `usage:
|
||||
pnpm scaffold page <Name> [--views A,B,C] [--no-tests] [--dry-run] [--force]
|
||||
pnpm scaffold component <Name> [--parent <path>] [--full] [--no-tests] [--dry-run] [--force]
|
||||
|
||||
examples:
|
||||
pnpm scaffold page ApiMonitoring
|
||||
pnpm scaffold page Traces --views Explorer,Funnels,Views
|
||||
pnpm scaffold page Traces/Explorer
|
||||
pnpm scaffold component DataTable
|
||||
pnpm scaffold component QueryBar --parent pages/Traces/Explorer`;
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`error: ${message}\n\n${USAGE}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function expandEquals(argv) {
|
||||
return argv.flatMap((arg) =>
|
||||
arg.startsWith('--') && arg.includes('=')
|
||||
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
|
||||
: [arg],
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const flags = {
|
||||
views: [],
|
||||
parent: 'components',
|
||||
full: false,
|
||||
tests: true,
|
||||
dryRun: false,
|
||||
force: false,
|
||||
};
|
||||
const positional = [];
|
||||
const provided = new Set();
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
provided.add(arg);
|
||||
if (arg === '--views' || arg === '--parent') {
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
fail(`${arg} needs a value`);
|
||||
}
|
||||
if (arg === '--views') {
|
||||
flags.views = value
|
||||
.split(',')
|
||||
.map((view) => view.trim())
|
||||
.filter(Boolean);
|
||||
if (!flags.views.length) {
|
||||
fail('--views needs at least one name');
|
||||
}
|
||||
} else {
|
||||
flags.parent = value;
|
||||
}
|
||||
i += 1;
|
||||
} else if (arg === '--full') {
|
||||
flags.full = true;
|
||||
} else if (arg === '--no-tests') {
|
||||
flags.tests = false;
|
||||
} else if (arg === '--dry-run') {
|
||||
flags.dryRun = true;
|
||||
} else if (arg === '--force') {
|
||||
flags.force = true;
|
||||
} else if (arg === '-h' || arg === '--help') {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
process.exit(0);
|
||||
} else if (arg.startsWith('-')) {
|
||||
fail(`unknown option: ${arg}`);
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { positional, flags, provided };
|
||||
}
|
||||
|
||||
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
|
||||
|
||||
// Folder names keep the casing the author typed — only the first letter is forced
|
||||
// up — so acronyms like `LLMObservability` survive. Separated names
|
||||
// (`api-monitoring`, `api monitoring`) collapse to PascalCase.
|
||||
function toDirName(value) {
|
||||
const name = value.trim().replace(/[^a-zA-Z0-9\-_ ]/g, '');
|
||||
if (!name) {
|
||||
fail(`"${value}" has no usable name characters`);
|
||||
}
|
||||
return /[-_\s]/.test(name)
|
||||
? name
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(capitalize)
|
||||
.join('')
|
||||
: capitalize(name);
|
||||
}
|
||||
|
||||
const splitHumps = (name, separator) =>
|
||||
name
|
||||
.replace(/([a-z0-9])([A-Z])/g, `$1${separator}$2`)
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, `$1${separator}$2`);
|
||||
|
||||
const toKebab = (value) => splitHumps(toDirName(value), '-').toLowerCase();
|
||||
const toTitle = (value) => splitHumps(toDirName(value), ' ');
|
||||
const toConst = (value) => toKebab(value).replace(/-/g, '_').toUpperCase();
|
||||
const toCamel = (value) => {
|
||||
const dir = toDirName(value);
|
||||
return dir.charAt(0).toLowerCase() + dir.slice(1);
|
||||
};
|
||||
|
||||
function tokensFor(name) {
|
||||
return {
|
||||
__Pascal__: toDirName(name),
|
||||
__kebab__: toKebab(name),
|
||||
__camel__: toCamel(name),
|
||||
__CONST__: toConst(name),
|
||||
__Title__: toTitle(name),
|
||||
};
|
||||
}
|
||||
|
||||
function substitute(text, tokens) {
|
||||
return Object.entries(tokens).reduce(
|
||||
(acc, [token, value]) => acc.split(token).join(value),
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
let targetExisted = false;
|
||||
|
||||
function writeFile(target, contents, flags) {
|
||||
const rel = relative(FRONTEND, target);
|
||||
if (existsSync(target) && !flags.force) {
|
||||
skipped.push(rel);
|
||||
return;
|
||||
}
|
||||
if (!flags.dryRun) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, contents);
|
||||
}
|
||||
created.push(rel);
|
||||
}
|
||||
|
||||
function createDirs(targetDir, dirs, flags) {
|
||||
for (const dir of dirs) {
|
||||
const target = join(targetDir, dir);
|
||||
const rel = `${relative(FRONTEND, target)}/`;
|
||||
if (existsSync(target)) {
|
||||
skipped.push(rel);
|
||||
continue;
|
||||
}
|
||||
if (!flags.dryRun) {
|
||||
mkdirSync(target, { recursive: true });
|
||||
}
|
||||
created.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
// Template files carry a `.tmpl` suffix so no TypeScript, lint or editor tooling
|
||||
// treats them as source; the suffix is dropped on the way out.
|
||||
function renderTree(templateDir, targetDir, tokens, flags) {
|
||||
for (const entry of readdirSync(templateDir).sort()) {
|
||||
const from = join(templateDir, entry);
|
||||
const name = substitute(entry.replace(/\.tmpl$/, ''), tokens);
|
||||
if (statSync(from).isDirectory()) {
|
||||
if (!flags.tests && name === '__tests__') {
|
||||
continue;
|
||||
}
|
||||
renderTree(from, join(targetDir, name), tokens, flags);
|
||||
} else {
|
||||
writeFile(
|
||||
join(targetDir, name),
|
||||
substitute(readFileSync(from, 'utf8'), tokens),
|
||||
flags,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shellTokens(views) {
|
||||
const viewImports = views
|
||||
.map((view) => `import ${toDirName(view)} from './${toDirName(view)}';`)
|
||||
.join('\n');
|
||||
const tabEntries = views
|
||||
.map((view) => {
|
||||
const path = '`${BASE_PATH}/' + toKebab(view) + '`';
|
||||
return [
|
||||
'\t{',
|
||||
`\t\tComponent: ${toDirName(view)},`,
|
||||
`\t\tname: '${toTitle(view)}',`,
|
||||
`\t\troute: ${path},`,
|
||||
`\t\tkey: ${path},`,
|
||||
'\t},',
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
return { __VIEW_IMPORTS__: viewImports, __TAB_ENTRIES__: `${tabEntries}\n` };
|
||||
}
|
||||
|
||||
function scaffoldFeature(targetDir, name, flags) {
|
||||
renderTree(join(TEMPLATES, 'feature'), targetDir, tokensFor(name), flags);
|
||||
createDirs(targetDir, FEATURE_DIRS, flags);
|
||||
}
|
||||
|
||||
function scaffoldPage(name, flags) {
|
||||
const segments = name.split('/').filter(Boolean).map(toDirName);
|
||||
if (!segments.length) {
|
||||
fail('page needs a name');
|
||||
}
|
||||
|
||||
const viewDirs = flags.views.map(toDirName);
|
||||
const duplicate = viewDirs.find((dir, index) => viewDirs.indexOf(dir) !== index);
|
||||
if (duplicate) {
|
||||
fail(`duplicate view: ${duplicate}`);
|
||||
}
|
||||
|
||||
const targetDir = join(SRC, 'pages', ...segments);
|
||||
const leaf = segments[segments.length - 1];
|
||||
targetExisted = existsSync(targetDir);
|
||||
|
||||
if (flags.views.length) {
|
||||
renderTree(
|
||||
join(TEMPLATES, 'shell'),
|
||||
targetDir,
|
||||
{ ...tokensFor(leaf), ...shellTokens(flags.views) },
|
||||
flags,
|
||||
);
|
||||
for (const view of flags.views) {
|
||||
scaffoldFeature(join(targetDir, toDirName(view)), view, flags);
|
||||
}
|
||||
} else {
|
||||
scaffoldFeature(targetDir, leaf, flags);
|
||||
}
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
function resolveParent(parent) {
|
||||
const segments = parent
|
||||
.replace(/^src\//, '')
|
||||
.replace(/\/components\/?$/, '')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (segments[0] === 'pages') {
|
||||
return ['pages', ...segments.slice(1).map(toDirName)];
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function scaffoldComponent(name, flags) {
|
||||
const tokens = tokensFor(name);
|
||||
const parent = resolveParent(flags.parent);
|
||||
const isGlobal = parent.length === 1 && parent[0] === 'components';
|
||||
const componentsDir = isGlobal
|
||||
? join(SRC, 'components')
|
||||
: join(SRC, ...parent, 'components');
|
||||
|
||||
if (relative(SRC, componentsDir).startsWith('..')) {
|
||||
fail(`--parent must stay inside src: ${flags.parent}`);
|
||||
}
|
||||
if (parent[0] === 'pages' && parent.length < 2) {
|
||||
fail('a component under pages/ needs a feature: --parent pages/<Feature>');
|
||||
}
|
||||
if (!isGlobal && !existsSync(join(SRC, ...parent))) {
|
||||
fail(`parent does not exist: src/${parent.join('/')}`);
|
||||
}
|
||||
|
||||
const targetDir = join(componentsDir, tokens.__Pascal__);
|
||||
targetExisted = existsSync(targetDir);
|
||||
|
||||
renderTree(join(TEMPLATES, 'component'), targetDir, tokens, flags);
|
||||
if (flags.full) {
|
||||
renderTree(join(TEMPLATES, 'component-extras'), targetDir, tokens, flags);
|
||||
createDirs(targetDir, FEATURE_DIRS, flags);
|
||||
}
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
function report(kind, targetDir, flags) {
|
||||
const rel = relative(FRONTEND, targetDir);
|
||||
const verb = flags.dryRun ? 'would create' : 'created';
|
||||
|
||||
if (targetExisted) {
|
||||
process.stdout.write(
|
||||
`\nwarning: ${rel} already existed — only missing entries were added\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${verb} ${created.length} entr(ies) in ${rel}\n`);
|
||||
for (const entry of created) {
|
||||
process.stdout.write(` + ${entry}\n`);
|
||||
}
|
||||
|
||||
if (skipped.length) {
|
||||
process.stdout.write(
|
||||
`\nskipped ${skipped.length} existing entr(ies) — pass --force to overwrite files\n`,
|
||||
);
|
||||
for (const entry of skipped) {
|
||||
process.stdout.write(` = ${entry}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const steps =
|
||||
kind === 'page'
|
||||
? [
|
||||
'register the route: src/constants/routes.ts, src/AppRoutes/pageComponents.ts, src/AppRoutes/routes.ts',
|
||||
...(flags.views.length
|
||||
? ['swap BASE_PATH in constants.ts for the new ROUTES entries']
|
||||
: []),
|
||||
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
|
||||
'fill in README.md',
|
||||
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
|
||||
]
|
||||
: [
|
||||
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
|
||||
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
|
||||
];
|
||||
|
||||
process.stdout.write('\nnext:\n');
|
||||
steps.forEach((step, index) => {
|
||||
process.stdout.write(` ${index + 1}. ${step}\n`);
|
||||
});
|
||||
process.stdout.write(
|
||||
'\nnote: git does not track empty folders — components/, hooks/ and store/ only\n' +
|
||||
'show up in a commit once they hold a file.\n',
|
||||
);
|
||||
}
|
||||
|
||||
const { positional, flags, provided } = parseArgs(expandEquals(process.argv.slice(2)));
|
||||
const [kind, name] = positional;
|
||||
|
||||
if (!kind || !name) {
|
||||
fail('a command and a name are required');
|
||||
}
|
||||
if (positional.length > 2) {
|
||||
fail(`unexpected argument: ${positional[2]}`);
|
||||
}
|
||||
|
||||
function rejectFlags(unsupported) {
|
||||
for (const flag of unsupported) {
|
||||
if (provided.has(flag)) {
|
||||
fail(`${flag} does not apply to \`${kind}\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let targetDir;
|
||||
if (kind === 'page') {
|
||||
rejectFlags(['--parent', '--full']);
|
||||
targetDir = scaffoldPage(name, flags);
|
||||
} else if (kind === 'component') {
|
||||
rejectFlags(['--views']);
|
||||
targetDir = scaffoldComponent(name, flags);
|
||||
} else {
|
||||
fail(`unknown command: ${kind}`);
|
||||
}
|
||||
|
||||
report(kind, targetDir, flags);
|
||||
@@ -0,0 +1,21 @@
|
||||
# __Pascal__
|
||||
|
||||
<!-- What this component renders, and the features that use it. -->
|
||||
|
||||
## API
|
||||
|
||||
<!-- Props, and the behaviour each one controls. -->
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `__Pascal__.tsx` | The component. |
|
||||
| `__Pascal__.module.scss` | Styles. |
|
||||
| `components/` | Child components this one owns. |
|
||||
| `hooks/` | Behaviour extracted out of the component. |
|
||||
| `store/` | Zustand stores this component owns. |
|
||||
| `types.ts` | Types shared inside this folder. |
|
||||
| `utils.ts` | Pure helpers. |
|
||||
| `constants.ts` | Constants. |
|
||||
| `__tests__/` | Tests. |
|
||||
@@ -0,0 +1,4 @@
|
||||
.__camel__ {
|
||||
display: flex;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import styles from './__Pascal__.module.scss';
|
||||
|
||||
function __Pascal__(): JSX.Element {
|
||||
return <div className={styles.__camel__} data-testid="__kebab__" />;
|
||||
}
|
||||
|
||||
export default __Pascal__;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import __Pascal__ from '../__Pascal__';
|
||||
|
||||
describe('__Pascal__', () => {
|
||||
it('renders', () => {
|
||||
render(<__Pascal__ />);
|
||||
|
||||
expect(screen.getByTestId('__kebab__')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
# __Title__
|
||||
|
||||
<!-- One paragraph: what this feature does, who uses it, and where it is reachable from. -->
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `index.tsx` | Feature entry. Composition only — no business logic. |
|
||||
| `components/` | Feature-local components, nested as `components/<Name>/`. |
|
||||
| `hooks/` | Feature hooks, including React Query wrappers over `api/generated`. |
|
||||
| `store/` | Zustand stores for feature-local client state. |
|
||||
| `types.ts` | Shared feature types. Split into `types/` past ~3 declarations. |
|
||||
| `utils.ts` | Pure helpers. |
|
||||
| `constants.ts` | Feature constants. |
|
||||
| `__tests__/` | Feature-root tests. Component tests live with the component. |
|
||||
|
||||
## Data
|
||||
|
||||
<!-- Endpoints this feature reads/writes, and the hooks that wrap them. -->
|
||||
|
||||
## State
|
||||
|
||||
<!-- What lives in the URL (nuqs), what lives in React Query, what lives in store/. -->
|
||||
|
||||
## Routing
|
||||
|
||||
<!-- Route key in constants/routes.ts, lazy import in AppRoutes/pageComponents.ts, entry in AppRoutes/routes.ts. -->
|
||||
@@ -0,0 +1,12 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import __Pascal__ from '../index';
|
||||
|
||||
describe('__Pascal__', () => {
|
||||
it('renders the page', () => {
|
||||
render(<__Pascal__ />);
|
||||
|
||||
expect(screen.getByTestId('__kebab__-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import styles from './__Pascal__.module.scss';
|
||||
|
||||
function __Pascal__(): JSX.Element {
|
||||
return (
|
||||
<section className={styles.container} data-testid="__kebab__-page">
|
||||
<h1 className={styles.title}>__Title__</h1>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default __Pascal__;
|
||||
@@ -0,0 +1,18 @@
|
||||
# __Title__
|
||||
|
||||
<!-- One paragraph: what this section of the product is, and what each tab is for. -->
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `index.tsx` | Shell. Tab switching only — no feature logic. |
|
||||
| `constants.ts` | Tab definitions (`TabRoutes`). |
|
||||
| `<View>/` | One folder per tab, each a self-contained feature. |
|
||||
|
||||
## Routing
|
||||
|
||||
`constants.ts` builds tab paths from a local `BASE_PATH`. Register those paths in
|
||||
`src/constants/routes.ts`, add the lazy import to `src/AppRoutes/pageComponents.ts` and
|
||||
the entries to `src/AppRoutes/routes.ts`, then replace `BASE_PATH` with the `ROUTES`
|
||||
entries so there is a single source of truth for each path.
|
||||
@@ -0,0 +1,6 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import __Pascal__ from '../index';
|
||||
|
||||
describe('__Pascal__', () => {
|
||||
it('renders the tab shell', () => {
|
||||
render(<__Pascal__ />);
|
||||
|
||||
expect(screen.getByTestId('__kebab__-shell')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
|
||||
__VIEW_IMPORTS__
|
||||
|
||||
const BASE_PATH = '/__kebab__';
|
||||
|
||||
export const __CONST___TABS: TabRoutes[] = [
|
||||
__TAB_ENTRIES__];
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import history from 'lib/history';
|
||||
|
||||
import { __CONST___TABS } from './constants';
|
||||
|
||||
import styles from './__Pascal__.module.scss';
|
||||
|
||||
function __Pascal__(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<div className={styles.shell} data-testid="__kebab__-shell">
|
||||
<RouteTab
|
||||
routes={__CONST___TABS}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
defaultActiveKey={__CONST___TABS[0].key}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default __Pascal__;
|
||||
@@ -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/'],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
|
||||
"dev": "vite",
|
||||
"scaffold": "node .claude/skills/scaffold-feature/scaffold.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -16,6 +17,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 +89,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 +128,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 +198,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 +239,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}/`)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||