Compare commits

..

4 Commits

Author SHA1 Message Date
Nityananda Gohain
599ae273c9 Merge branch 'main' into issue_5123_2 2026-07-06 09:03:03 -07:00
nityanandagohain
36edbeca38 fix: move error from normalize to validate 2026-07-06 21:32:00 +05:30
Nityananda Gohain
0436655a43 Merge branch 'main' into issue_5123_2 2026-06-08 12:34:18 +05:30
nityanandagohain
301d496092 chore: add normalize for QueryRangeRequest 2026-06-05 12:31:19 +05:30
151 changed files with 619 additions and 7492 deletions

View File

@@ -102,44 +102,3 @@ jobs:
run: |
cd frontend && pnpm generate:config:web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
code-quality:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: install-frontend
run: cd frontend && pnpm install
- name: get-new-files
id: new-files
run: |
cd frontend
NEW_TS=$(git diff --name-only --diff-filter=A origin/main...HEAD -- src/ | grep -E '\.(ts|tsx)$' | tr '\n' ' ' || true)
NEW_SCSS=$(git diff --name-only --diff-filter=A origin/main...HEAD -- src/ | grep -E '\.scss$' | tr '\n' ' ' || true)
echo "ts=$NEW_TS" >> $GITHUB_OUTPUT
echo "scss=$NEW_SCSS" >> $GITHUB_OUTPUT
echo "New TS files: $NEW_TS"
echo "New SCSS files: $NEW_SCSS"
- name: lint-new-ts-files
if: steps.new-files.outputs.ts != ''
run: |
cd frontend
pnpm oxlint -c .oxlintrc.strict.json ${{ steps.new-files.outputs.ts }}
- name: lint-new-scss-files
if: steps.new-files.outputs.scss != ''
run: |
cd frontend
pnpm stylelint --config .stylelintrc.strict.cjs ${{ steps.new-files.outputs.scss }}

View File

@@ -155,8 +155,8 @@ querier:
cache_ttl: 168h
# The interval for recent data that should not be cached.
flux_interval: 5m
# The maximum number of queries a single query range request runs at once.
max_concurrent_queries: 8
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.

View File

@@ -4261,8 +4261,6 @@ components:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- clusterName
- clusterCPU
@@ -4271,7 +4269,6 @@ components:
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesClusters:
@@ -4327,8 +4324,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- daemonSetName
- daemonSetCPU
@@ -4340,7 +4335,6 @@ components:
- desiredNodes
- currentNodes
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDaemonSets:
@@ -4396,8 +4390,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- deploymentName
- deploymentCPU
@@ -4409,7 +4401,6 @@ components:
- desiredPods
- availablePods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDeployments:
@@ -4542,8 +4533,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
successfulPods:
type: integer
required:
@@ -4559,7 +4548,6 @@ components:
- failedPods
- successfulPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesJobs:
@@ -4650,14 +4638,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- namespaceName
- namespaceCPU
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesNamespaces:
@@ -4723,14 +4708,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
@@ -4776,64 +4758,6 @@ components:
- failed
- unknown
type: object
InframonitoringtypesPodCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
evicted:
type: integer
failed:
type: integer
imagePullBackOff:
type: integer
nodeAffinity:
type: integer
nodeLost:
type: integer
oomKilled:
type: integer
pending:
type: integer
running:
type: integer
shutdown:
type: integer
unexpectedAdmissionError:
type: integer
unknown:
type: integer
required:
- pending
- running
- failed
- unknown
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- evicted
- nodeAffinity
- nodeLost
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -4864,8 +4788,6 @@ components:
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
podMemory:
format: double
type: number
@@ -4877,11 +4799,6 @@ components:
type: number
podPhase:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podRestarts:
format: int64
type: integer
podStatus:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
podUID:
type: string
required:
@@ -4894,34 +4811,9 @@ components:
- podMemoryLimit
- podPhase
- podCountsByPhase
- podStatus
- podCountsByStatus
- podRestarts
- podAge
- meta
type: object
InframonitoringtypesPodStatus:
enum:
- pending
- running
- failed
- unknown
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- evicted
- nodeaffinity
- nodelost
- shutdown
- unexpectedadmissionerror
- no_data
type: string
InframonitoringtypesPods:
properties:
endTimeBeforeRetention:
@@ -5220,8 +5112,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
statefulSetCPU:
format: double
type: number
@@ -5253,7 +5143,6 @@ components:
- desiredPods
- currentPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesStatefulSets:

View File

@@ -1,149 +0,0 @@
# Authz
SigNoz uses [OpenFGA](https://openfga.dev/), a relationship-based access control (ReBAC) system, to authorize every request. Transactions are never attached to users directly — they are attached to **roles** (as relationship tuples in OpenFGA), and users or service accounts (principals) are made **assignees** of those roles. The central interface is `AuthZ` in [pkg/authz/authz.go](/pkg/authz/authz.go), backed by an embedded OpenFGA server in [pkg/authz/openfgaserver](/pkg/authz/openfgaserver/server.go).
As a feature author, you will rarely touch OpenFGA directly. You interact with two layers:
1. **The registries** in [pkg/types/coretypes](/pkg/types/coretypes) — where every resource, verb, and managed-role permission is declared in code.
2. **The route wiring** in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver) — where each route declares what resource it touches and which verb it needs, and the middleware turns that into a check.
## What are the building blocks?
### Type
A `Type` is the coarse FGA type of a resource. The set is finite and defined in [pkg/types/coretypes/registry_type.go](/pkg/types/coretypes/registry_type.go): `user`, `serviceaccount`, `anonymous`, `role`, `organization`, `metaresource`, and `telemetryresource`. Each type carries a selector regex (what a valid ID looks like) and the verbs allowed on it.
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines, ...) or a `telemetryresource` (logs, traces, metrics). You should almost never need a new type.
### Verb
A `Verb` is the action being authorized. All verbs are defined in [pkg/types/coretypes/registry_verb.go](/pkg/types/coretypes/registry_verb.go): `create`, `read`, `update`, `delete`, `list`, `assignee`, `attach`, and `detach`.
- `assignee` is special: it is the membership relation between a subject and a role ("user X is an assignee of role Y"), not an action a route checks for.
- `attach`/`detach` authorize linking two resources together (e.g. assigning a role to a service account).
### Kind
A `Kind` is the fine-grained name of your resource — `dashboard`, `rule`, `quick-filter`. Kinds are registered in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go). A kind rides on top of an existing type, so adding one does **not** require any OpenFGA schema change.
### Resource
A `Resource` ties a type and a kind together and knows how to render itself as an FGA object string. The interface lives in [pkg/types/coretypes/resource.go](/pkg/types/coretypes/resource.go):
```go
type Resource interface {
Type() Type
Kind() Kind
Prefix(orgId valuer.UUID) string // metaresource:organization/<orgID>/dashboard
Object(orgId valuer.UUID, selector string) string
Scope(verb Verb) string // dashboard:read
AllowedVerbs() []Verb
}
```
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
### Selector
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
- `WildcardSelector` — the check is against all instances of the resource (`create`, `list`).
- `IDSelector` — the check is against the specific instance *or* the wildcard (`read`, `update`, `delete` of one object). A subject authorized on `*` is authorized on every instance.
When the ID in the request is not what FGA needs (e.g. routes receive a role UUID but FGA objects use role names), write a custom `SelectorFunc` — see `roleSelector` in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
### Roles, transactions, and tuples
SigNoz ships four managed roles, declared in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go): `signoz-admin`, `signoz-editor`, `signoz-viewer`, and `signoz-anonymous`. Their permissions are declared in code as `Transaction`s (a verb on an object) in `ManagedRoleToTransactions` — this map is the single source of truth for what each managed role can do.
At organization bootstrap, `CreateManagedRoles` and `CreateManagedUserRoleTransactions` (see [pkg/authz/authz.go](/pkg/authz/authz.go)) persist the role rows and write one OpenFGA tuple per transaction, linking `role:organization/<orgID>/role/<name>#assignee` to each permitted object. Users and service accounts are then granted roles via `Grant`/`Revoke`, which writes `assignee` tuples. Custom roles (enterprise) are managed through the roles API in [pkg/authz/signozauthzapi/handler.go](/pkg/authz/signozauthzapi/handler.go).
### Schema
The OpenFGA authorization model is a hand-written DSL, embedded at build time: [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) for community and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) for enterprise. The community model only supports role assignment; the enterprise model defines per-verb relations on every type, enabling genuine per-resource checks. This split is why `CheckWithTupleCreation` behaves differently per edition (see [How does a check work at runtime?](#how-does-a-check-work-at-runtime)). You only touch these files when introducing a brand-new **type** — never for a new kind.
## How do I add authz to my feature?
### 1. Register the kind
Add your kind in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go) and append it to `Kinds`:
```go
KindThing = MustNewKind("thing")
```
### 2. Register the resource
Add the resource in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) and append it to `Resources`:
```go
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
```
Pass an explicit verb list to `NewResourceMetaResource` only if your resource supports fewer verbs than the type default.
### 3. Grant permissions to managed roles
Decide what each managed role can do with your resource and add the transactions in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go):
```go
// thing — editors manage, viewers read
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
```
The convention so far: admin gets everything, editor gets CRUD on day-to-day observability resources, viewer gets `read`/`list`, anonymous gets nothing (except public dashboards).
### 4. Wire the route
Register the route in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver), wrapping your handler with `CheckResources` and declaring what the route touches via a `ResourceDef` ([pkg/http/handler/resourcedef.go](/pkg/http/handler/resourcedef.go)). A complete example from [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go):
```go
router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
// ...
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost)
```
The pieces:
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
### 5. Backfill existing organizations (only if needed)
Managed-role tuples are written from the registry at organization creation, so **new resources need no migration for new organizations**. If existing organizations must get the new permissions, add a migration in [pkg/sqlmigration](/pkg/sqlmigration) that inserts the tuples — see [083_add_role_crud_tuples.go](/pkg/sqlmigration/083_add_role_crud_tuples.go) for the pattern.
## How does a check work at runtime?
1. The resource middleware ([pkg/http/middleware/resource.go](/pkg/http/middleware/resource.go)) runs on every request. It reads the matched handler's `ResourceDef`s, extracts the resource IDs from path/body, and stores the resolved resources in the request context.
2. `CheckResources` reads them back, runs each `SelectorFunc`, and calls `AuthZ.CheckWithTupleCreation(ctx, claims, orgID, relation, resource, selectors, roleSelectors)`.
3. What happens next depends on the edition:
- **Community** ([pkg/authz/openfgaserver/server.go](/pkg/authz/openfgaserver/server.go)) ignores the resource and selectors and only checks whether the subject is an `assignee` of one of the allowed roles — a plain role gate.
- **Enterprise** ([ee/authz/openfgaserver/server.go](/ee/authz/openfgaserver/server.go)) builds tuples via `authtypes.NewTuples` — subject `user:organization/<orgID>/user/<userID>`, relation `create`, object `serviceaccount:organization/<orgID>/serviceaccount/*` — and batch-checks them against OpenFGA: genuine per-resource authorization, including custom roles.
Because both paths go through the same middleware and the same `ResourceDef` declarations, a route wired once works correctly in both editions.
## What should I remember?
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.
- Linking routes check **both** sides: peers via `AttachDetachSiblingResourceDef` (same verb on both), parent-child via a `BasicResourceDef` on the child (`create`/`delete`) paired with an `AttachDetachParentChildResourceDef` on the parent (`attach`/`detach`).
- Changing `ManagedRoleToTransactions` only affects organizations created afterwards — add a [pkg/sqlmigration](/pkg/sqlmigration) migration to backfill existing ones.

View File

@@ -11,7 +11,6 @@ We adhere to three primary style guides as our foundation:
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
- [Authz](authz.md) - Authorization, roles, and access control
- [Errors](errors.md) - Structured error handling
- [Endpoint](endpoint.md) - HTTP endpoint patterns
- [Flagger](flagger.md) - Feature flag patterns

View File

@@ -295,18 +295,6 @@
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"signoz/no-dashboard-fetch-outside-root": "error",
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
"signoz/no-raw-html-text-elements": "warn",
// Bans raw span/p/h1-6 elements - use Typography component instead
"signoz/no-multiple-components": "warn",
// Bans multiple component exports per file - split into separate files
"signoz/no-inline-helpers": "warn",
// Bans too many helper functions in component files - move to utils.ts
"signoz/max-component-lines": ["warn", { "max": 300 }],
// Warns when component files exceed max lines - split into smaller components
"signoz/no-getByText": "warn",
// Bans getByText in tests - use getByTestId or getByRole instead
"signoz/no-fireEvent": "warn",
// Bans fireEvent in tests - use userEvent instead
"no-restricted-globals": [
"error",
{

View File

@@ -1,18 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["./.oxlintrc.json"],
"env": {
"builtin": true,
"es2021": true,
"browser": true,
"jest": true,
"node": true
},
"rules": {
"signoz/no-raw-html-text-elements": "error",
"signoz/no-multiple-components": "error",
"signoz/no-inline-helpers": "error",
"signoz/no-getByText": "error",
"signoz/no-fireEvent": "error"
}
}

View File

@@ -12,17 +12,11 @@ module.exports = {
),
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
path.join(__dirname, 'stylelint-rules/no-font-properties.js'),
path.join(__dirname, 'stylelint-rules/no-primitive-color-tokens.js'),
path.join(__dirname, 'stylelint-rules/no-styles-scss.js'),
],
customSyntax: 'postcss-scss',
rules: {
// Applies to all SCSS files
'local/no-unsupported-asset-url': true,
'local/no-font-properties': [true, { severity: 'warning' }],
'local/no-primitive-color-tokens': [true, { severity: 'warning' }],
'local/no-styles-scss': [true, { severity: 'warning' }],
},
overrides: [
{

View File

@@ -1,14 +0,0 @@
// Strict config for new files - rules that are warnings in base config become errors here
// oxlint-disable-next-line typescript/no-require-imports
const baseConfig = require('./.stylelintrc.cjs');
module.exports = {
...baseConfig,
rules: {
...baseConfig.rules,
// Override warnings to errors for new files
'local/no-font-properties': true, // error (no severity = error)
'local/no-primitive-color-tokens': true,
'local/no-styles-scss': true,
},
};

View File

@@ -1,3 +0,0 @@
export const IS_DEV = false;
export const IS_PROD = true;
export const MODE = 'test';

View File

@@ -29,7 +29,6 @@ const config: Config.InitialOptions = {
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
'^react-syntax-highlighter/dist/esm/(.*)$':
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',

View File

@@ -16,8 +16,6 @@
"lint:generated": "oxlint ./src/api/generated --fix",
"lint:fix": "oxlint ./src --fix",
"lint:styles": "stylelint \"src/**/*.scss\"",
"lint:strict": "oxlint -c .oxlintrc.strict.json",
"lint:styles:strict": "stylelint --config .stylelintrc.strict.cjs",
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",

View File

@@ -1,71 +0,0 @@
/**
* Rule: max-component-lines
*
* Warns when a component file exceeds configured line limit.
* Large components should be split into smaller ones.
*
* Config: ["warn", { "max": 300 }]
* Default: 300 lines
*
* This rule should always be "warn" (never "error").
*/
const DEFAULT_MAX_LINES = 300;
export default {
meta: {
docs: {
description: 'Warn when component files exceed a line limit',
category: 'Code Quality',
},
schema: [
{
type: 'object',
properties: {
max: {
type: 'number',
minimum: 1,
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options?.[0] || {};
const maxLines = options.max || DEFAULT_MAX_LINES;
const filename = context.getFilename();
// Only check .tsx files (component files)
if (!/\.tsx$/.test(filename)) {
return {};
}
// Skip test files
if (/\.(test|spec)\.tsx$/.test(filename)) {
return {};
}
// Skip utility/type files
if (
/\/(utils|helpers|constants|types|hooks)\.(ts|tsx)$/.test(filename) ||
/\/(utils|helpers|constants|types)\//.test(filename)
) {
return {};
}
return {
Program(node) {
const sourceCode = context.getSourceCode();
const lines = sourceCode.lines?.length || sourceCode.getText().split('\n').length;
if (lines > maxLines) {
context.report({
node,
message: `Component file has ${lines} lines (max ${maxLines}). Consider splitting into smaller components or extracting logic into hooks/utils.`,
});
}
},
};
},
};

View File

@@ -1,48 +0,0 @@
/**
* Rule: no-fireEvent
*
* Bans fireEvent in test files. Use userEvent instead for more realistic
* user interaction simulation.
*
* BAD:
* fireEvent.click(button)
* fireEvent.change(input, { target: { value: 'test' } })
*
* GOOD:
* await userEvent.click(button)
* await userEvent.type(input, 'test')
*/
export default {
create(context) {
return {
// fireEvent.click(...), fireEvent.change(...)
MemberExpression(node) {
if (
node.object.type === 'Identifier' &&
node.object.name === 'fireEvent'
) {
context.report({
node,
message:
"Avoid 'fireEvent'. Use userEvent from @testing-library/user-event for more realistic user interactions.",
});
}
},
// import { fireEvent } from '@testing-library/react'
ImportSpecifier(node) {
if (
node.imported.type === 'Identifier' &&
node.imported.name === 'fireEvent'
) {
context.report({
node,
message:
"Avoid importing 'fireEvent'. Use userEvent from @testing-library/user-event instead.",
});
}
},
};
},
};

View File

@@ -1,53 +0,0 @@
/**
* Rule: no-getByText
*
* Bans getByText, queryByText, findByText, getAllByText, queryAllByText, findAllByText
* in test files. Use getByTestId or getByRole instead.
*
* BAD:
* screen.getByText('Submit')
* getByText('Submit')
*
* GOOD:
* screen.getByTestId('submit-button')
* screen.getByRole('button', { name: 'Submit' })
*/
const BANNED_METHODS = new Set([
'getByText',
'queryByText',
'findByText',
'getAllByText',
'queryAllByText',
'findAllByText',
]);
export default {
create(context) {
return {
CallExpression(node) {
let methodName = null;
// screen.getByText(...) or result.getByText(...)
if (
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier'
) {
methodName = node.callee.property.name;
}
// Direct getByText(...) from destructured import
if (node.callee.type === 'Identifier') {
methodName = node.callee.name;
}
if (methodName && BANNED_METHODS.has(methodName)) {
context.report({
node,
message: `Avoid '${methodName}'. Use getByTestId or getByRole instead for more stable selectors.`,
});
}
},
};
},
};

View File

@@ -1,161 +0,0 @@
/**
* Rule: no-inline-helpers
*
* Bans too many helper functions in component files.
* Helper functions should be moved to utils.ts or specialized files.
*
* Exempt:
* - Hooks (use* functions)
* - Exported functions (they're intentionally in the file)
* - Files named utils.ts, helpers.ts, constants.ts
*
* Threshold: More than 3 private helper functions triggers warning.
*/
const MAX_HELPERS = 3;
function isHookName(name) {
return /^use[A-Z]/.test(name);
}
function isComponentName(name) {
return /^[A-Z]/.test(name);
}
export default {
create(context) {
const filename = context.getFilename();
// Skip utility files
if (
/\/(utils|helpers|constants|types|hooks)\.(ts|tsx|js|jsx)$/.test(filename) ||
/\/(utils|helpers|constants|types)\//.test(filename)
) {
return {};
}
// Skip test files
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(filename)) {
return {};
}
const privateHelpers = [];
const exportedNames = new Set();
let hasComponent = false;
return {
// Track exported names
ExportNamedDeclaration(node) {
if (node.declaration) {
if (node.declaration.type === 'FunctionDeclaration' && node.declaration.id) {
exportedNames.add(node.declaration.id.name);
if (isComponentName(node.declaration.id.name)) {
hasComponent = true;
}
}
if (node.declaration.type === 'VariableDeclaration') {
for (const decl of node.declaration.declarations) {
if (decl.id.type === 'Identifier') {
exportedNames.add(decl.id.name);
if (isComponentName(decl.id.name)) {
hasComponent = true;
}
}
}
}
}
},
ExportDefaultDeclaration(node) {
if (
node.declaration.type === 'FunctionDeclaration' &&
node.declaration.id &&
isComponentName(node.declaration.id.name)
) {
hasComponent = true;
}
if (node.declaration.type === 'Identifier') {
exportedNames.add(node.declaration.name);
}
},
// Track private function declarations
FunctionDeclaration(node) {
if (!node.id) {
return;
}
const name = node.id.name;
// Will check if exported later
if (
!isHookName(name) &&
!isComponentName(name) &&
node.parent.type !== 'ExportNamedDeclaration' &&
node.parent.type !== 'ExportDefaultDeclaration'
) {
privateHelpers.push({ name, node });
}
},
// Track private arrow/function expressions
VariableDeclaration(node) {
// Skip if this is an export
if (
node.parent.type === 'ExportNamedDeclaration' ||
node.parent.type === 'Program'
) {
for (const decl of node.declarations) {
if (decl.id.type !== 'Identifier') {
continue;
}
const name = decl.id.name;
const init = decl.init;
// Only track functions
if (
!init ||
(init.type !== 'ArrowFunctionExpression' &&
init.type !== 'FunctionExpression')
) {
continue;
}
// Skip hooks, components, and exports
if (
!isHookName(name) &&
!isComponentName(name) &&
node.parent.type !== 'ExportNamedDeclaration'
) {
privateHelpers.push({ name, node: decl });
}
}
}
},
'Program:exit'() {
// Only check files with components
if (!hasComponent) {
return;
}
// Filter out any that ended up exported
const actualPrivate = privateHelpers.filter(
(h) => !exportedNames.has(h.name),
);
if (actualPrivate.length > MAX_HELPERS) {
// Report on helpers beyond the threshold
for (let i = MAX_HELPERS; i < actualPrivate.length; i++) {
const { name, node } = actualPrivate[i];
context.report({
node,
message: `Too many helper functions (${actualPrivate.length}) in component file. Move '${name}' and other helpers to utils.ts. Helpers: ${actualPrivate.map((h) => h.name).join(', ')}.`,
});
}
}
},
};
},
};

View File

@@ -1,142 +0,0 @@
/**
* Rule: no-multiple-components
*
* Bans multiple React component exports in a single file.
* Each component should have its own file.
*
* BAD:
* export function ComponentA() { return <div /> }
* export function ComponentB() { return <div /> }
*
* GOOD:
* // ComponentA.tsx
* export function ComponentA() { return <div /> }
*
* // ComponentB.tsx (separate file)
* export function ComponentB() { return <div /> }
*
* Note: index.tsx files are exempt (re-export barrels).
*/
function isPascalCase(name) {
return /^[A-Z][a-zA-Z0-9]*$/.test(name);
}
function hasJSXReturn(node) {
if (!node.body) {
return false;
}
// Arrow function with direct JSX return: () => <div />
if (node.body.type === 'JSXElement' || node.body.type === 'JSXFragment') {
return true;
}
// Function body - check return statements
if (node.body.type === 'BlockStatement') {
for (const stmt of node.body.body) {
if (
stmt.type === 'ReturnStatement' &&
stmt.argument &&
(stmt.argument.type === 'JSXElement' ||
stmt.argument.type === 'JSXFragment' ||
// Conditional: condition ? <A /> : <B />
(stmt.argument.type === 'ConditionalExpression' &&
(stmt.argument.consequent.type === 'JSXElement' ||
stmt.argument.alternate.type === 'JSXElement')) ||
// Logical: condition && <A />
(stmt.argument.type === 'LogicalExpression' &&
(stmt.argument.left.type === 'JSXElement' ||
stmt.argument.right.type === 'JSXElement')))
) {
return true;
}
}
}
return false;
}
export default {
create(context) {
const exportedComponents = [];
return {
// export function ComponentName() { }
ExportNamedDeclaration(node) {
if (!node.declaration) {
return;
}
// function ComponentName() {}
if (node.declaration.type === 'FunctionDeclaration') {
const name = node.declaration.id?.name;
if (name && isPascalCase(name) && hasJSXReturn(node.declaration)) {
exportedComponents.push({ name, node });
}
}
// const ComponentName = () => {} or const ComponentName = function() {}
if (node.declaration.type === 'VariableDeclaration') {
for (const declarator of node.declaration.declarations) {
const name = declarator.id?.name;
if (!name || !isPascalCase(name)) {
continue;
}
const init = declarator.init;
if (!init) {
continue;
}
// Arrow function or function expression
if (
(init.type === 'ArrowFunctionExpression' ||
init.type === 'FunctionExpression') &&
hasJSXReturn(init)
) {
exportedComponents.push({ name, node });
}
// React.memo(Component) or memo(Component)
if (
init.type === 'CallExpression' &&
((init.callee.type === 'Identifier' &&
(init.callee.name === 'memo' ||
init.callee.name === 'forwardRef')) ||
(init.callee.type === 'MemberExpression' &&
init.callee.object.name === 'React' &&
(init.callee.property.name === 'memo' ||
init.callee.property.name === 'forwardRef')))
) {
exportedComponents.push({ name, node });
}
}
}
},
// export default function ComponentName() { }
ExportDefaultDeclaration(node) {
if (node.declaration.type === 'FunctionDeclaration') {
const name = node.declaration.id?.name;
if (name && isPascalCase(name) && hasJSXReturn(node.declaration)) {
exportedComponents.push({ name, node });
}
}
},
'Program:exit'() {
if (exportedComponents.length > 1) {
// Report on all but the first component
for (let i = 1; i < exportedComponents.length; i++) {
const { name, node } = exportedComponents[i];
context.report({
node,
message: `Multiple component exports in same file. Move '${name}' to its own file. Found ${exportedComponents.length} components: ${exportedComponents.map((c) => c.name).join(', ')}.`,
});
}
}
},
};
},
};

View File

@@ -1,39 +0,0 @@
/**
* Rule: no-raw-html-text-elements
*
* Bans raw HTML text elements (span, p, h1-h6) in JSX.
* Use Typography component from @signozhq/ui instead.
*
* BAD:
* <span>Hello</span>
* <p>Paragraph</p>
* <h1>Title</h1>
*
* GOOD:
* <Typography>Hello</Typography>
* <Typography.Text>Hello</Typography.Text>
* <Typography.Title level={1}>Title</Typography.Title>
*/
const BANNED_ELEMENTS = new Set(['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
export default {
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier') {
return;
}
const elementName = node.name.name;
if (BANNED_ELEMENTS.has(elementName)) {
context.report({
node,
message: `Avoid raw '<${elementName}>' element. Use Typography component from @signozhq/ui instead.`,
});
}
},
};
},
};

View File

@@ -13,12 +13,6 @@ 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 noGetByText from './rules/no-getByText.mjs';
import noFireEvent from './rules/no-fireEvent.mjs';
import noRawHtmlTextElements from './rules/no-raw-html-text-elements.mjs';
import noMultipleComponents from './rules/no-multiple-components.mjs';
import noInlineHelpers from './rules/no-inline-helpers.mjs';
import maxComponentLines from './rules/max-component-lines.mjs';
export default {
meta: {
@@ -33,11 +27,5 @@ export default {
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-getByText': noGetByText,
'no-fireEvent': noFireEvent,
'no-raw-html-text-elements': noRawHtmlTextElements,
'no-multiple-components': noMultipleComponents,
'no-inline-helpers': noInlineHelpers,
'max-component-lines': maxComponentLines,
},
};

View File

@@ -5704,81 +5704,6 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
unknown: number;
}
export interface InframonitoringtypesPodCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
evicted: number;
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
nodeAffinity: number;
/**
* @type integer
*/
nodeLost: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
shutdown: number;
/**
* @type integer
*/
unexpectedAdmissionError: number;
/**
* @type integer
*/
unknown: number;
}
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
@@ -5810,7 +5735,6 @@ export interface InframonitoringtypesClusterRecordDTO {
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export enum InframonitoringtypesResponseTypeDTO {
@@ -5914,7 +5838,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
@@ -5992,7 +5915,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
@@ -6159,7 +6081,6 @@ export interface InframonitoringtypesJobRecordDTO {
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type integer
*/
@@ -6213,7 +6134,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
*/
namespaceName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNamespacesDTO {
@@ -6280,7 +6200,6 @@ export interface InframonitoringtypesNodeRecordDTO {
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNodesDTO {
@@ -6318,27 +6237,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6365,7 +6263,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -6382,12 +6279,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podMemoryRequest: number;
podPhase: InframonitoringtypesPodPhaseDTO;
/**
* @type integer
* @format int64
*/
podRestarts: number;
podStatus: InframonitoringtypesPodStatusDTO;
/**
* @type string
*/
@@ -6705,7 +6596,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double

View File

@@ -2,7 +2,6 @@ import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface CheckboxValueRowProps {
value: string;
@@ -47,11 +46,9 @@ function CheckboxValueRow({
{customRendererForValue ? (
customRendererForValue(value)
) : (
<TooltipSimple title={value} side="top" align="center" arrow>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
</TooltipSimple>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}

View File

@@ -22,7 +22,6 @@ import {
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { IS_DEV } from 'lib/env';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -31,33 +30,6 @@ import { useCmdK } from '../../providers/cmdKProvider';
import './cmdKPalette.scss';
const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;
const AuthZDevFloatingIndicator = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
(m) => ({
default: m.AuthZDevFloatingIndicator,
}),
),
)
: null;
const openAuthZDevModal = IS_DEV
? (): void => {
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
m.openAuthZDevModal();
return m;
});
}
: undefined;
type CmdAction = {
id: string;
name: string;
@@ -138,7 +110,6 @@ export function CmdKPalette({
aiAssistant: isAIAssistantEnabled
? { open: handleOpenAIAssistant }
: undefined,
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
});
// RBAC filter: show action if no roles set OR current user role is included
@@ -175,57 +146,37 @@ export function CmdKPalette({
};
return (
<>
<CommandDialog
open={open}
onOpenChange={setOpen}
position="top"
offset={110}
>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
>
<span
className={cx(
'cmd-item-icon',
it.id === 'ai-assistant' && 'noz-icon',
)}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
{IS_DEV && AuthZDevModal && (
<React.Suspense fallback={null}>
<AuthZDevModal />
</React.Suspense>
)}
{IS_DEV && AuthZDevFloatingIndicator && (
<React.Suspense fallback={null}>
<AuthZDevFloatingIndicator />
</React.Suspense>
)}
</>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -31,7 +31,6 @@ export enum LOCALSTORAGE {
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -43,17 +43,10 @@ type ActionDeps = {
aiAssistant?: {
open: () => void;
};
/**
* Provided only in development mode. Opens the AuthZ DevTools modal
* for testing permission overrides.
*/
authzDevTools?: {
open: () => void;
};
};
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
const { navigate, handleThemeChange, aiAssistant } = deps;
const actions: CmdAction[] = [
{
@@ -309,17 +302,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
});
}
if (authzDevTools) {
actions.push({
id: 'authz-devtools',
name: 'AuthZ DevTools',
keywords: 'authz permissions rbac debug devtools override testing',
section: 'Dev',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: authzDevTools.open,
});
}
return actions;
}

View File

@@ -177,13 +177,7 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => {
handleSend(
text,
undefined,
autoContexts.length > 0 ? autoContexts : undefined,
);
}}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

View File

@@ -1,142 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { fireEvent, render } from '@testing-library/react';
import { MessageContext } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
const CHIP_ID = 'recent-errors';
const CHIP_TEXT = 'Show me recent errors';
// Auto-derived page context that a normal (typed) send would attach. The chip
// send must forward the exact same array.
const mockAutoContexts: MessageContext[] = [
{
source: 'auto',
type: 'dashboard',
resourceId: 'dashboard-123',
resourceName: 'Checkout dashboard',
},
];
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/getAutoContexts', () => ({
getAutoContexts: jest.fn(() => mockAutoContexts),
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ threadId: 'thread-1' }),
}));
// ChatInput is heavy and irrelevant here — the chip path lives entirely in the
// empty state. Provide a lightweight stub plus the `autoContextKey` named export
// ConversationView imports for its dismissed-context filter.
jest.mock('container/AIAssistant/components/ChatInput', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="chat-input" />,
autoContextKey: (): string => '',
}));
jest.mock('container/AIAssistant/components/ConversationSkeleton', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="skeleton" />,
}));
// VirtualizedMessages renders the real empty-state chips; stub only its
// never-rendered-in-empty-state children and the virtual list.
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/components/MessageBubble', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('container/AIAssistant/components/StreamingMessage', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('react-virtuoso', () => ({
__esModule: true,
Virtuoso: (): null => null,
}));
jest.mock(
'container/AIAssistant/components/VirtualizedMessages/useEmptyStateChips',
() => ({
useEmptyStateChips: (): { chips: { id: string; text: string }[] } => ({
chips: [{ id: CHIP_ID, text: CHIP_TEXT }],
}),
}),
);
// eslint-disable-next-line import/first
import ConversationView from '../ConversationView';
const CONVERSATION_ID = 'conv-1';
function renderView(variant: 'panel' | 'page' | 'modal'): {
getByTestId: (id: string) => HTMLElement;
} {
return render(
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
<VariantContext.Provider value={variant}>
<ConversationView conversationId={CONVERSATION_ID} />
</VariantContext.Provider>
</MemoryRouter>,
);
}
describe('ConversationView — empty-state chip context', () => {
let sendMessage: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
sendMessage = jest.fn();
useAIAssistantStore.setState({
conversations: {
[CONVERSATION_ID]: {
id: CONVERSATION_ID,
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: CONVERSATION_ID,
isLoadingThread: false,
sendMessage,
} as unknown as Partial<ReturnType<typeof useAIAssistantStore.getState>>);
});
it('forwards the page auto-contexts when a chip is clicked (embedded variant)', () => {
const { getByTestId } = renderView('panel');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
// The chip send must carry the same auto-contexts a typed message would.
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
CHIP_TEXT,
undefined,
mockAutoContexts,
);
});
it('sends undefined contexts on the standalone page (no page context to attach)', () => {
const { getByTestId } = renderView('page');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(CHIP_TEXT, undefined, undefined);
});
});

View File

@@ -149,18 +149,6 @@
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
overflow-wrap: anywhere;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.dashboard-variables {

View File

@@ -43,7 +43,6 @@ import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { linkifyText } from 'utils/linkifyText';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
@@ -516,9 +515,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
<section className="dashboard-description-section">{description}</section>
)}
{!isEmpty(dashboardVariables) && (

View File

@@ -1625,9 +1625,6 @@ export const getHostQueryPayload = (
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
const fsUsageKey = dotMetricsEnabled
? 'system.filesystem.usage'
: 'system_filesystem_usage';
return [
{
@@ -2660,155 +2657,6 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'A',
filters: {
items: [
{
id: 'fs_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
{
id: 'fs_f2',
key: {
dataType: DataTypes.String,
id: 'state--string--tag--false',
key: 'state',
type: 'tag',
},
op: '=',
value: 'used',
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'B',
filters: {
items: [
{
id: 'fs_f3',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
legend: '{{mountpoint}}',
queryName: 'F1',
},
],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2884,5 +2732,4 @@ export const hostWidgetInfo = [
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
];

View File

@@ -48,8 +48,6 @@ describe('CreateRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -77,8 +77,6 @@ describe('EditRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -409,8 +409,6 @@ describe('ViewRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -1,28 +0,0 @@
.container {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 9998;
pointer-events: auto;
display: flex;
align-items: center;
gap: 4px;
}
.button {
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}
.badge {
margin-left: 4px;
}
.closeButton {
border-radius: 4px;
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}

View File

@@ -1,61 +0,0 @@
import { X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useAuthZDevStore } from '../useAuthZDevStore';
import styles from './AuthZDevFloatingIndicator.module.css';
export function AuthZDevFloatingIndicator(): JSX.Element | null {
const overrides = useAuthZDevStore((s) => s.overrides);
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
const openModal = useAuthZDevStore((s) => s.openModal);
const [isDismissed, setIsDismissed] = useState(false);
const overrideCount = Object.keys(overrides).length;
if (overrideCount === 0 || isModalOpen || isDismissed) {
return null;
}
const handleOpen = (): void => {
setIsDismissed(false);
openModal();
};
const handleDismiss = (e: React.MouseEvent): void => {
e.stopPropagation();
setIsDismissed(true);
};
return createPortal(
<div className={styles.container}>
<Button
variant="solid"
color="warning"
size="sm"
onClick={handleOpen}
className={styles.button}
data-testid="authz-dev-floating-indicator"
>
AuthZ Overrides
<Badge color="warning" className={styles.badge}>
{overrideCount}
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={handleDismiss}
className={styles.closeButton}
aria-label="Dismiss indicator"
data-testid="authz-dev-floating-dismiss"
prefix={<X />}
/>
</div>,
document.body,
);
}

View File

@@ -1,140 +0,0 @@
.modal {
--dialog-width: 640px;
--dialog-max-width: 92vw;
--dialog-max-height: 78vh;
--dialog-description-padding: var(--spacing-4) var(--spacing-4) 0px
var(--spacing-4);
[data-slot='dialog-description'],
[data-slot='dialog-header'] {
background-color: var(--l2-background);
}
}
.content {
display: flex;
flex-direction: column;
max-height: calc(78vh - 80px);
}
.header {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: var(--spacing-4);
padding-bottom: var(--spacing-4);
}
.searchRow {
display: flex;
align-items: center;
gap: var(--spacing-4);
--input-background: var(--l3-background);
--input-hover-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
--input-hover-border-color: var(--l3-border);
--input-focus-border-color: var(--l3-border);
--select-trigger-background-color: var(--l3-background);
--select-trigger-hover-background: var(--l3-background);
--select-trigger-focus-background: var(--l3-background);
--select-trigger-border-color: var(--l3-border);
--select-content-background: var(--l3-background);
--select-item-highlight-background: var(--l3-background-hover);
}
.search {
flex: 1 1 auto;
min-width: 0;
--input-width: 100%;
}
.filter {
flex: 0 0 176px;
/* Normalize the library trigger height (2.25rem) to match the input. */
--select-trigger-height: 2rem;
}
.search > *,
.filter > * {
box-sizing: border-box;
}
.searchIcon {
display: inline-flex;
color: var(--l3-foreground);
}
.actionsRow {
display: flex;
gap: var(--spacing-3);
}
.actionButton {
flex: 0 0 auto;
height: 2rem;
}
.list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4) 0;
overflow-y: auto;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sectionHeader {
display: flex;
align-items: baseline;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-2);
margin: 0 0 var(--spacing-1);
border-bottom: 1px solid var(--l2-border);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
padding: var(--spacing-16);
}
.footer {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l2-border);
}
.hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.hintGroup {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.count {
flex: 0 0 auto;
white-space: nowrap;
}

View File

@@ -1,108 +0,0 @@
import { DialogWrapper } from '@signozhq/ui/dialog';
import { useCallback, useRef } from 'react';
import { useAuthZDevStore } from '../useAuthZDevStore';
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
import { AuthZDevModalContent } from './AuthZDevModalContent';
import { AuthZDevModalFooter } from './AuthZDevModalFooter';
import { AuthZDevModalHeader } from './AuthZDevModalHeader';
import { useAuthZDevModalData } from './useAuthZDevModalData';
import { useModalKeyboard } from './useModalKeyboard';
import styles from './AuthZDevModal.module.css';
export function AuthZDevModal(): JSX.Element | null {
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
const closeModal = useAuthZDevStore((s) => s.closeModal);
const observed = useAuthZDevStore((s) => s.observed);
const overrides = useAuthZDevStore((s) => s.overrides);
const cycleOverride = useAuthZDevStore((s) => s.cycleOverride);
const setOverride = useAuthZDevStore((s) => s.setOverride);
const clearAllOverrides = useAuthZDevStore((s) => s.clearAllOverrides);
const grantAll = useAuthZDevStore((s) => s.grantAll);
const denyAll = useAuthZDevStore((s) => s.denyAll);
useAuthZQueryInvalidation(overrides);
const searchInputRef = useRef<HTMLInputElement>(null);
const {
search,
setSearch,
resourceFilter,
setResourceFilter,
observedList,
resourceFilterItems,
filteredPermissions,
groups,
orderedPermissions,
indexByPermission,
hasActiveFilter,
filteredOverrideCount,
overrideCount,
} = useAuthZDevModalData(observed, overrides);
const { selectedIndex, setSelectedIndex } = useModalKeyboard({
permissions: orderedPermissions,
overrides,
onCycle: cycleOverride,
onSetOverride: setOverride,
onClose: closeModal,
searchInputRef,
});
const handleOpenChange = useCallback(
(open: boolean): void => {
if (!open) {
closeModal();
setSelectedIndex(-1);
}
},
[closeModal, setSelectedIndex],
);
return (
<DialogWrapper
open={isModalOpen}
onOpenChange={handleOpenChange}
title="AuthZ DevTools"
subTitle="Force permission results locally without touching the backend."
className={styles.modal}
width="wide"
>
<div className={styles.content}>
<AuthZDevModalHeader
searchInputRef={searchInputRef}
search={search}
setSearch={setSearch}
resourceFilter={resourceFilter}
setResourceFilter={setResourceFilter}
resourceFilterItems={resourceFilterItems}
hasActiveFilter={hasActiveFilter}
filteredPermissions={filteredPermissions}
filteredOverrideCount={filteredOverrideCount}
overrideCount={overrideCount}
grantAll={grantAll}
denyAll={denyAll}
clearAllOverrides={clearAllOverrides}
/>
<AuthZDevModalContent
observedListLength={observedList.length}
orderedPermissions={orderedPermissions}
groups={groups}
indexByPermission={indexByPermission}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
observed={observed}
overrides={overrides}
onSetOverride={setOverride}
/>
<AuthZDevModalFooter
orderedPermissionsCount={orderedPermissions.length}
observedListLength={observedList.length}
/>
</div>
</DialogWrapper>
);
}

View File

@@ -1,85 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback } from 'react';
import { BrandedPermission } from '../../hooks/useAuthZ/types';
import { ObservedPermission, OverrideState } from '../types';
import { PermissionRow } from './PermissionRow';
import styles from './AuthZDevModal.module.css';
export interface PermissionGroup {
resource: string;
items: string[];
}
export interface AuthZDevModalContentProps {
observedListLength: number;
orderedPermissions: string[];
groups: PermissionGroup[];
indexByPermission: Map<string, number>;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
observed: Record<string, ObservedPermission>;
overrides: Record<string, OverrideState>;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
}
export function AuthZDevModalContent({
observedListLength,
orderedPermissions,
groups,
indexByPermission,
selectedIndex,
setSelectedIndex,
observed,
overrides,
onSetOverride,
}: AuthZDevModalContentProps): JSX.Element {
const handleSelectIndex = useCallback(
(index: number) => (): void => {
setSelectedIndex(index);
},
[setSelectedIndex],
);
return (
<div className={styles.list} data-testid="authz-dev-permission-list">
{orderedPermissions.length === 0 ? (
<div className={styles.empty}>
<Typography.Text align="center" color="muted">
{observedListLength === 0
? 'No permissions observed yet. Navigate the app to trigger permission checks.'
: 'No permissions match your search.'}
</Typography.Text>
</div>
) : (
groups.map((group) => (
<div key={group.resource} className={styles.section}>
<div className={styles.sectionHeader}>
<Typography.Text as="span" size="medium" weight="semibold">
{group.resource}
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
{group.items.length}
</Typography.Text>
</div>
{group.items.map((permission) => {
const index = indexByPermission.get(permission) ?? 0;
return (
<PermissionRow
key={permission}
observed={observed[permission]}
override={overrides[permission]}
isSelected={index === selectedIndex}
onSetOverride={onSetOverride}
onSelect={handleSelectIndex(index)}
/>
);
})}
</div>
))
)}
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { Kbd } from '@signozhq/ui/kbd';
import { Typography } from '@signozhq/ui/typography';
import styles from './AuthZDevModal.module.css';
export interface AuthZDevModalFooterProps {
orderedPermissionsCount: number;
observedListLength: number;
}
export function AuthZDevModalFooter({
orderedPermissionsCount,
observedListLength,
}: AuthZDevModalFooterProps): JSX.Element {
return (
<div className={styles.footer}>
<div className={styles.hint}>
<span className={styles.hintGroup}>
<Kbd></Kbd>
<Kbd></Kbd>
<Typography.Text as="span" size="small" color="muted">
navigate
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd></Kbd>
<Kbd></Kbd>
<Typography.Text as="span" size="small" color="muted">
mode
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>1-5</Kbd>
<Typography.Text as="span" size="small" color="muted">
set
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>/</Kbd>
<Typography.Text as="span" size="small" color="muted">
search
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>Esc</Kbd>
<Typography.Text as="span" size="small" color="muted">
close
</Typography.Text>
</span>
</div>
<Typography.Text size="small" color="muted" className={styles.count}>
{orderedPermissionsCount} of {observedListLength} permissions
</Typography.Text>
</div>
);
}

View File

@@ -1,119 +0,0 @@
import { Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { RefObject, useCallback } from 'react';
import { BrandedPermission } from '../../hooks/useAuthZ/types';
import styles from './AuthZDevModal.module.css';
export interface AuthZDevModalHeaderProps {
searchInputRef: RefObject<HTMLInputElement>;
search: string;
setSearch: (value: string) => void;
resourceFilter: string;
setResourceFilter: (value: string) => void;
resourceFilterItems: Array<{ value: string; label: string }>;
hasActiveFilter: boolean;
filteredPermissions: BrandedPermission[];
filteredOverrideCount: number;
overrideCount: number;
grantAll: (permissions?: BrandedPermission[]) => void;
denyAll: (permissions?: BrandedPermission[]) => void;
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
}
export function AuthZDevModalHeader({
searchInputRef,
search,
setSearch,
resourceFilter,
setResourceFilter,
resourceFilterItems,
hasActiveFilter,
filteredPermissions,
filteredOverrideCount,
overrideCount,
grantAll,
denyAll,
clearAllOverrides,
}: AuthZDevModalHeaderProps): JSX.Element {
const handleGrantAll = useCallback((): void => {
grantAll(hasActiveFilter ? filteredPermissions : undefined);
}, [grantAll, hasActiveFilter, filteredPermissions]);
const handleDenyAll = useCallback((): void => {
denyAll(hasActiveFilter ? filteredPermissions : undefined);
}, [denyAll, hasActiveFilter, filteredPermissions]);
const handleClearAll = useCallback((): void => {
clearAllOverrides(hasActiveFilter ? filteredPermissions : undefined);
}, [clearAllOverrides, hasActiveFilter, filteredPermissions]);
return (
<div className={styles.header}>
<div className={styles.searchRow}>
<div className={styles.search}>
<Input
ref={searchInputRef}
placeholder="Search permissions..."
value={search}
onChange={(e): void => setSearch(e.target.value)}
prefix={<Search size={14} className={styles.searchIcon} />}
aria-label="Search permissions"
data-testid="authz-dev-search"
/>
</div>
<div className={styles.filter}>
<SelectSimple
items={resourceFilterItems}
value={resourceFilter}
onChange={(value): void => setResourceFilter(value as string)}
testId="authz-dev-resource-filter"
withPortal={false}
/>
</div>
</div>
<div className={styles.actionsRow}>
<Button
className={styles.actionButton}
variant="outlined"
color="success"
size="sm"
onClick={handleGrantAll}
disabled={filteredPermissions.length === 0}
data-testid="authz-dev-grant-all"
>
{hasActiveFilter ? 'Grant filtered' : 'Grant all'}
</Button>
<Button
className={styles.actionButton}
variant="outlined"
color="error"
size="sm"
onClick={handleDenyAll}
disabled={filteredPermissions.length === 0}
data-testid="authz-dev-deny-all"
>
{hasActiveFilter ? 'Deny filtered' : 'Deny all'}
</Button>
<Button
className={styles.actionButton}
variant="outlined"
color="secondary"
size="sm"
onClick={handleClearAll}
disabled={
hasActiveFilter ? filteredOverrideCount === 0 : overrideCount === 0
}
data-testid="authz-dev-clear-all"
>
{hasActiveFilter
? `Clear filtered (${filteredOverrideCount})`
: `Clear all (${overrideCount})`}
</Button>
</div>
</div>
);
}

View File

@@ -1,72 +0,0 @@
.segmented {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1);
background: var(--l2-background);
border: 1px solid var(--l3-border);
border-radius: var(--radius-2);
}
.segment {
--button-height: 22px;
--button-padding: 0 var(--spacing-3);
--button-secondary-ghost-hover-foreground: var(--l2-foreground);
--button-variant-ghost-background-color: transparent;
border: none;
--button-border-radius: calc(var(--radius-2) - 1px);
transition:
background 120ms ease,
color 120ms ease;
}
.segment:not(.segmentActive):hover {
--button-secondary-ghost-hover-foreground: var(--l2-foreground-hover);
--button-variant-ghost-background-color: var(--l2-background);
}
.segmentIcon {
display: inline-flex;
align-items: center;
}
.segment.optAuto {
--button-secondary-ghost-hover-foreground: var(--l1-foreground);
--button-variant-ghost-background-color: var(--l3-background);
}
.segment.optGranted {
--button-secondary-ghost-hover-foreground: var(--success-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-forest) 22%,
transparent
);
}
.segment.optDenied {
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-cherry) 22%,
transparent
);
}
.segment.optDelay {
--button-secondary-ghost-hover-foreground: var(--warning-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-amber) 22%,
transparent
);
}
.segment.optError {
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-cherry) 22%,
transparent
);
}

View File

@@ -1,93 +0,0 @@
import { Check, Clock, RotateCcw, X, Zap } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { OverrideState } from '../types';
import styles from './OverrideControl.module.css';
import { Button } from '@signozhq/ui/button';
type OverrideControlProps = {
permission: BrandedPermission;
value: OverrideState;
onSelect: (permission: BrandedPermission, state: OverrideState) => void;
};
type OverrideOption = {
state: OverrideState;
label: string;
icon: React.ReactNode;
activeClassName: string;
};
const OVERRIDE_OPTIONS: OverrideOption[] = [
{
state: OverrideState.Reset,
label: 'Auto',
icon: <RotateCcw size={13} />,
activeClassName: styles.optAuto,
},
{
state: OverrideState.Granted,
label: 'Grant',
icon: <Check size={13} />,
activeClassName: styles.optGranted,
},
{
state: OverrideState.Denied,
label: 'Deny',
icon: <X size={13} />,
activeClassName: styles.optDenied,
},
{
state: OverrideState.Delay,
label: 'Delay',
icon: <Clock size={13} />,
activeClassName: styles.optDelay,
},
{
state: OverrideState.Error,
label: 'Error',
icon: <Zap size={13} />,
activeClassName: styles.optError,
},
];
export function OverrideControl({
permission,
value,
onSelect,
}: OverrideControlProps): JSX.Element {
return (
<div className={styles.segmented}>
{OVERRIDE_OPTIONS.map((option) => {
const isActive = value === option.state;
return (
<Button
key={option.state}
type="button"
aria-pressed={isActive}
aria-label={option.label}
title={option.label}
className={cx(styles.segment, {
[styles.segmentActive]: isActive,
[option.activeClassName]: isActive,
})}
variant="ghost"
color="secondary"
onClick={(): void => onSelect(permission, option.state)}
data-testid={`override-${option.state}-${permission}`}
>
<div className={styles.segmentIcon}>{option.icon}</div>
{isActive && (
<Typography.Text as="span" size="small" weight="medium">
{option.label}
</Typography.Text>
)}
</Button>
);
})}
</div>
);
}

View File

@@ -1,68 +0,0 @@
.permissionRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
padding: var(--spacing-2);
border: 1px solid transparent;
border-radius: var(--radius-2);
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.permissionRow:hover {
background: var(--l2-background-hover);
}
/* Overridden rows carry a faint full border in the override color. */
.permissionRow.rowGranted {
border-color: color-mix(in srgb, var(--accent-forest) 45%, transparent);
}
.permissionRow.rowDenied {
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
}
.permissionRow.rowDelay {
border-color: color-mix(in srgb, var(--accent-amber) 45%, transparent);
}
.permissionRow.rowError {
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
}
/* Keyboard selection wins over the override border. */
.permissionRow.isSelected {
border-color: var(--primary);
}
.permissionInfo {
display: flex;
flex: 1 1 auto;
align-items: baseline;
gap: var(--spacing-2);
min-width: 0;
}
.relation {
flex: 0 0 auto;
--typography-color: var(--accent-primary);
}
.separator {
flex: 0 0 auto;
}
.object {
flex: 0 1 auto;
min-width: 0;
}
.permissionMeta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--spacing-5);
}

View File

@@ -1,114 +0,0 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import { OverrideState, type ObservedPermission } from '../types';
import { OverrideControl } from './OverrideControl';
import styles from './PermissionRow.module.css';
type PermissionRowProps = {
observed: ObservedPermission;
override: OverrideState | undefined;
isSelected: boolean;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
onSelect: () => void;
};
const ROW_OVERRIDE_CLASSES: Record<OverrideState, string | null> = {
[OverrideState.Reset]: null,
[OverrideState.Granted]: styles.rowGranted,
[OverrideState.Denied]: styles.rowDenied,
[OverrideState.Delay]: styles.rowDelay,
[OverrideState.Error]: styles.rowError,
};
export const PermissionRow = memo(function PermissionRow({
observed,
override,
isSelected,
onSetOverride,
onSelect,
}: PermissionRowProps): JSX.Element {
const currentState = override ?? OverrideState.Reset;
const { relation, objectId } = useMemo(() => {
const parsed = parsePermission(observed.permission);
const separatorIndex = parsed.object.indexOf(':');
return {
relation: parsed.relation,
objectId:
separatorIndex === -1
? parsed.object
: parsed.object.slice(separatorIndex + 1),
};
}, [observed.permission]);
const handleSetOverride = useCallback(
(permission: BrandedPermission, state: OverrideState): void => {
onSelect();
onSetOverride(permission, state);
},
[onSelect, onSetOverride],
);
let apiColor: BadgeColor = 'secondary';
let apiLabel = 'API ?';
if (observed.apiValue === true) {
apiColor = 'success';
apiLabel = 'API ✓';
} else if (observed.apiValue === false) {
apiColor = 'error';
apiLabel = 'API ✗';
}
return (
<div
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState], {
[styles.isSelected]: isSelected,
})}
data-testid={`permission-row-${observed.permission}`}
>
<div className={styles.permissionInfo}>
<Typography.Text
as="span"
size="small"
weight="medium"
className={styles.relation}
>
{relation}
</Typography.Text>
<Typography.Text
as="span"
size="small"
color="muted"
className={styles.separator}
>
:
</Typography.Text>
<Typography.Text
as="span"
size="small"
truncate={1}
className={styles.object}
>
{objectId}
</Typography.Text>
</div>
<div className={styles.permissionMeta}>
<Badge variant="outline" color={apiColor}>
{apiLabel}
</Badge>
<OverrideControl
permission={observed.permission}
value={currentState}
onSelect={handleSetOverride}
/>
</div>
</div>
);
});

View File

@@ -1,172 +0,0 @@
import { useMemo, useState } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import type { ObservedPermission, OverrideState } from '../types';
type SelectItem = {
value: string;
label: string;
};
type PermissionGroup = {
resource: string;
items: BrandedPermission[];
};
type UseAuthZDevModalDataResult = {
search: string;
setSearch: (search: string) => void;
resourceFilter: string;
setResourceFilter: (filter: string) => void;
observedList: ObservedPermission[];
resourceFilterItems: SelectItem[];
filteredPermissions: BrandedPermission[];
groups: PermissionGroup[];
orderedPermissions: BrandedPermission[];
indexByPermission: Map<string, number>;
hasActiveFilter: boolean;
filteredOverrideCount: number;
overrideCount: number;
};
export function useAuthZDevModalData(
observed: Record<string, ObservedPermission>,
overrides: Record<string, OverrideState>,
): UseAuthZDevModalDataResult {
const [search, setSearch] = useState('');
const [resourceFilter, setResourceFilter] = useState<string>('all');
const observedList = useMemo(
() =>
Object.values(observed).sort((a, b) =>
a.permission.localeCompare(b.permission),
),
[observed],
);
const resources = useMemo(() => {
const resourceSet = new Set<string>();
for (const obs of observedList) {
const { object } = parsePermission(obs.permission);
const resource = object.split(':')[0];
resourceSet.add(resource);
}
return Array.from(resourceSet).sort();
}, [observedList]);
const resourceFilterItems = useMemo<SelectItem[]>(
() => [
{ value: 'all', label: 'All resources' },
...resources.map((resource) => ({
value: resource,
label: resource,
})),
],
[resources],
);
const filteredPermissions = useMemo(() => {
let filtered = observedList;
if (search) {
const searchLower = search.toLowerCase();
filtered = filtered.filter((obs) =>
obs.permission.toLowerCase().includes(searchLower),
);
}
if (resourceFilter !== 'all') {
filtered = filtered.filter((obs) => {
const { object } = parsePermission(obs.permission);
const resource = object.split(':')[0];
return resource === resourceFilter;
});
}
return filtered.map((obs) => obs.permission);
}, [observedList, search, resourceFilter]);
const { groups, orderedPermissions } = useMemo(() => {
const groupMap = new Map<string, BrandedPermission[]>();
for (const permission of filteredPermissions) {
const { object } = parsePermission(permission);
const resource = object.split(':')[0] || 'other';
const bucket = groupMap.get(resource);
if (bucket) {
bucket.push(permission);
} else {
groupMap.set(resource, [permission]);
}
}
const sortItems = (items: BrandedPermission[]): BrandedPermission[] =>
[...items].sort((a, b) => {
const objA = parsePermission(a).object;
const objB = parsePermission(b).object;
const idA = objA.split(':')[1] ?? '';
const idB = objB.split(':')[1] ?? '';
const isWildcardA = idA === '*';
const isWildcardB = idB === '*';
// Wildcards first
if (isWildcardA && !isWildcardB) {
return -1;
}
if (!isWildcardA && isWildcardB) {
return 1;
}
// Then by object ID, then by full permission
const idCompare = idA.localeCompare(idB);
if (idCompare !== 0) {
return idCompare;
}
return a.localeCompare(b);
});
const sortedGroups = Array.from(groupMap, ([resource, items]) => ({
resource,
items: sortItems(items),
})).sort((a, b) => a.resource.localeCompare(b.resource));
return {
groups: sortedGroups,
orderedPermissions: sortedGroups.flatMap((group) => group.items),
};
}, [filteredPermissions]);
const indexByPermission = useMemo(() => {
const map = new Map<string, number>();
orderedPermissions.forEach((permission, index) => {
map.set(permission, index);
});
return map;
}, [orderedPermissions]);
const hasActiveFilter = search !== '' || resourceFilter !== 'all';
const filteredOverrideCount = useMemo(() => {
if (!hasActiveFilter) {
return Object.keys(overrides).length;
}
return filteredPermissions.filter((p) => p in overrides).length;
}, [hasActiveFilter, overrides, filteredPermissions]);
const overrideCount = Object.keys(overrides).length;
return {
search,
setSearch,
resourceFilter,
setResourceFilter,
observedList,
resourceFilterItems,
filteredPermissions,
groups,
orderedPermissions,
indexByPermission,
hasActiveFilter,
filteredOverrideCount,
overrideCount,
};
}

View File

@@ -1,174 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { OverrideState, OVERRIDE_CYCLE } from '../types';
type UseModalKeyboardOptions = {
permissions: BrandedPermission[];
overrides: Record<string, OverrideState>;
onCycle: (permission: BrandedPermission) => void;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
onClose: () => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
};
type UseModalKeyboardResult = {
selectedIndex: number;
setSelectedIndex: (index: number) => void;
};
type KeyContext = {
permissions: BrandedPermission[];
overrides: Record<string, OverrideState>;
selectedIndex: number;
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>;
onCycle: (permission: BrandedPermission) => void;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
};
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const NUMBER_KEY_INDEX: Record<string, number> = {
'1': 0,
'2': 1,
'3': 2,
'4': 3,
'5': 4,
};
function stepOverrideState(
current: OverrideState,
direction: number,
): OverrideState {
const currentIndex = OVERRIDE_CYCLE.indexOf(current);
const nextIndex =
(currentIndex + direction + OVERRIDE_CYCLE.length) % OVERRIDE_CYCLE.length;
return OVERRIDE_CYCLE[nextIndex];
}
// Arrow keys stay active even while the search input is focused so the list can
// be driven without leaving the search field.
function handleArrowKey(key: string, ctx: KeyContext): void {
if (key === 'ArrowDown') {
ctx.setSelectedIndex((prev) =>
Math.min(prev + 1, ctx.permissions.length - 1),
);
return;
}
if (key === 'ArrowUp') {
ctx.setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
const selected = ctx.permissions[ctx.selectedIndex];
if (!selected) {
return;
}
const direction = key === 'ArrowLeft' ? -1 : 1;
ctx.onSetOverride(
selected,
stepOverrideState(ctx.overrides[selected] ?? OverrideState.Reset, direction),
);
}
// Number and space/enter shortcuts type into the search field, so they only run
// when it is not focused. Returns whether the key was handled.
function handleActionKey(key: string, ctx: KeyContext): boolean {
const selected = ctx.permissions[ctx.selectedIndex];
const numberIndex = NUMBER_KEY_INDEX[key];
if (numberIndex !== undefined) {
if (selected) {
ctx.onSetOverride(selected, OVERRIDE_CYCLE[numberIndex]);
}
return true;
}
if (key === ' ' || key === 'Enter') {
if (selected) {
ctx.onCycle(selected);
}
return true;
}
return false;
}
export function useModalKeyboard({
permissions,
overrides,
onCycle,
onSetOverride,
onClose,
searchInputRef,
}: UseModalKeyboardOptions): UseModalKeyboardResult {
// Start with no selection (-1) to avoid accidental override changes from
// Enter keypress that opened the modal also triggering cycleOverride.
const [selectedIndex, setSelectedIndex] = useState(-1);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isSearchFocused = document.activeElement === searchInputRef.current;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === '/') {
if (!isSearchFocused) {
e.preventDefault();
searchInputRef.current?.focus();
}
return;
}
const ctx: KeyContext = {
permissions,
overrides,
selectedIndex,
setSelectedIndex,
onCycle,
onSetOverride,
};
if (ARROW_KEYS.has(e.key)) {
e.preventDefault();
handleArrowKey(e.key, ctx);
return;
}
if (isSearchFocused) {
return;
}
if (handleActionKey(e.key, ctx)) {
e.preventDefault();
}
},
[
permissions,
overrides,
selectedIndex,
onCycle,
onSetOverride,
onClose,
searchInputRef,
],
);
useEffect((): (() => void) => {
window.addEventListener('keydown', handleKeyDown);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
useEffect((): void => {
if (selectedIndex >= permissions.length && permissions.length > 0) {
setSelectedIndex(permissions.length - 1);
}
}, [permissions.length, selectedIndex]);
return {
selectedIndex,
setSelectedIndex,
};
}

View File

@@ -1,46 +0,0 @@
import type { BrandedPermission } from '../hooks/useAuthZ/types';
export enum OverrideState {
Granted = 'granted',
Denied = 'denied',
Delay = 'delay',
Error = 'error',
Reset = 'reset',
}
export type ObservedPermission = {
permission: BrandedPermission;
apiValue: boolean | null;
lastSeen: number;
};
export type PermissionOverride = {
permission: BrandedPermission;
state: OverrideState;
};
export type AuthZDevStore = {
isModalOpen: boolean;
observed: Record<string, ObservedPermission>;
overrides: Record<string, OverrideState>;
openModal: () => void;
closeModal: () => void;
toggleModal: () => void;
registerObserved: (permission: BrandedPermission, apiValue: boolean) => void;
setOverride: (permission: BrandedPermission, state: OverrideState) => void;
clearOverride: (permission: BrandedPermission) => void;
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
grantAll: (permissions?: BrandedPermission[]) => void;
denyAll: (permissions?: BrandedPermission[]) => void;
cycleOverride: (permission: BrandedPermission) => void;
};
export const OVERRIDE_CYCLE: OverrideState[] = [
OverrideState.Reset,
OverrideState.Granted,
OverrideState.Denied,
OverrideState.Delay,
OverrideState.Error,
];

View File

@@ -1,137 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { BrandedPermission } from '../hooks/useAuthZ/types';
import { OverrideState, OVERRIDE_CYCLE, type AuthZDevStore } from './types';
import { getScopedKey } from 'utils/storage';
export const useAuthZDevStore = create<AuthZDevStore>()(
persist(
(set, get) => ({
isModalOpen: false,
observed: {},
overrides: {},
openModal: (): void => {
set({ isModalOpen: true });
},
closeModal: (): void => {
set({ isModalOpen: false });
},
toggleModal: (): void => {
set((state) => ({ isModalOpen: !state.isModalOpen }));
},
registerObserved: (
permission: BrandedPermission,
apiValue: boolean,
): void => {
set((state) => ({
observed: {
...state.observed,
[permission]: {
permission,
apiValue,
lastSeen: Date.now(),
},
},
}));
},
setOverride: (permission: BrandedPermission, state: OverrideState): void => {
if (state === OverrideState.Reset) {
get().clearOverride(permission);
return;
}
set((s) => ({
overrides: {
...s.overrides,
[permission]: state,
},
}));
},
clearOverride: (permission: BrandedPermission): void => {
set((state) => {
const { [permission]: _, ...rest } = state.overrides;
return { overrides: rest };
});
},
clearAllOverrides: (permissions?: BrandedPermission[]): void => {
if (permissions) {
set((state) => {
const newOverrides = { ...state.overrides };
for (const permission of permissions) {
delete newOverrides[permission];
}
return { overrides: newOverrides };
});
} else {
set({ overrides: {} });
}
},
grantAll: (permissions?: BrandedPermission[]): void => {
set((state) => {
const keys = permissions ?? Object.keys(state.observed);
const newOverrides: Record<string, OverrideState> = {
...state.overrides,
};
for (const key of keys) {
newOverrides[key] = OverrideState.Granted;
}
return { overrides: newOverrides };
});
},
denyAll: (permissions?: BrandedPermission[]): void => {
set((state) => {
const keys = permissions ?? Object.keys(state.observed);
const newOverrides: Record<string, OverrideState> = {
...state.overrides,
};
for (const key of keys) {
newOverrides[key] = OverrideState.Denied;
}
return { overrides: newOverrides };
});
},
cycleOverride: (permission: BrandedPermission): void => {
const currentOverride = get().overrides[permission] ?? OverrideState.Reset;
const currentIndex = OVERRIDE_CYCLE.indexOf(currentOverride);
const nextIndex = (currentIndex + 1) % OVERRIDE_CYCLE.length;
const nextState = OVERRIDE_CYCLE[nextIndex];
get().setOverride(permission, nextState);
},
}),
{
name: `@signoz/${getScopedKey('authz-dev-overrides')}`,
partialize: (state) => {
// Clear apiValue for permissions without active override (auto mode)
// since the API value can change between sessions
const observed: typeof state.observed = {};
for (const [key, obs] of Object.entries(state.observed)) {
observed[key] = {
...obs,
apiValue: key in state.overrides ? obs.apiValue : null,
};
}
return {
observed,
overrides: state.overrides,
};
},
},
),
);
export const openAuthZDevModal = (): void =>
useAuthZDevStore.getState().openModal();
export const closeAuthZDevModal = (): void =>
useAuthZDevStore.getState().closeModal();
export const toggleAuthZDevModal = (): void =>
useAuthZDevStore.getState().toggleModal();

View File

@@ -1,28 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from 'react-query';
import type { OverrideState } from './types';
type Overrides = Record<string, OverrideState>;
export function useAuthZQueryInvalidation(overrides: Overrides): void {
const queryClient = useQueryClient();
const prevOverridesRef = useRef<Overrides>(overrides);
useEffect(() => {
const prevOverrides = prevOverridesRef.current;
prevOverridesRef.current = overrides;
const allKeys = new Set([
...Object.keys(prevOverrides),
...Object.keys(overrides),
]);
for (const key of allKeys) {
if (prevOverrides[key] !== overrides[key]) {
// Reset query to initial state and trigger refetch for active observers
void queryClient.resetQueries(['authz', key]);
}
}
}, [overrides, queryClient]);
}

View File

@@ -89,13 +89,5 @@ export type UseAuthZResult = {
isFetching: boolean;
error: Error | null;
permissions: AuthZCheckResponse | null;
/**
* True if every check is granted. False while loading or on error.
*/
allowed: boolean;
/**
* Checks that resolved as not granted (empty while loading/error).
*/
deniedPermissions: BrandedPermission[];
refetchPermissions: () => void;
};

View File

@@ -1,6 +1,5 @@
import { ReactElement } from 'react';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useQueryClient } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AllTheProviders } from 'tests/test-utils';
@@ -47,16 +46,12 @@ describe('useAuthZ', () => {
expect(result.current.isLoading).toBe(true);
expect(result.current.permissions).toBeNull();
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.permissions).toStrictEqual(expectedResponse);
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([permission2]);
});
it('should return error and null permissions when API errors', async () => {
@@ -78,89 +73,6 @@ describe('useAuthZ', () => {
expect(result.current.error).not.toBeNull();
expect(result.current.permissions).toBeNull();
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([]);
});
it('should set allowed to true when all permissions are granted', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [true, true])),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.allowed).toBe(true);
expect(result.current.deniedPermissions).toStrictEqual([]);
});
it('should collect all denied permissions when multiple are denied', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false])),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([
permission1,
permission2,
]);
});
it('should not fetch when enabled is false', async () => {
let requestCount = 0;
const permission = buildPermission('read', 'role:*');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount += 1;
const payload = await req.json();
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
}),
);
const { result } = renderHook(
() => useAuthZ([permission], { enabled: false }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(0);
expect(result.current.allowed).toBe(false);
expect(result.current.permissions).toStrictEqual({});
});
it('should refetch when permissions array changes', async () => {
@@ -562,120 +474,3 @@ describe('useAuthZ', () => {
expect(result2.current.permissions).not.toHaveProperty(permission1);
});
});
describe('useAuthZ cache invalidation', () => {
it('should re-render with updated data when query is invalidated', async () => {
const permission = buildPermission('read', 'role:*');
let requestCount = 0;
let shouldGrant = true;
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [shouldGrant])),
);
}),
);
const { result } = renderHook(
() => {
const queryClient = useQueryClient();
const authz = useAuthZ([permission]);
return { authz, queryClient };
},
{ wrapper },
);
await waitFor(() => {
expect(result.current.authz.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
expect(result.current.authz.allowed).toBe(true);
expect(result.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: true },
});
// Change server response and reset query (forces refetch)
shouldGrant = false;
await act(async () => {
await result.current.queryClient.resetQueries(['authz', permission]);
});
await waitFor(() => {
expect(result.current.authz.allowed).toBe(false);
});
expect(requestCount).toBe(2);
expect(result.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
});
it('should re-render all components using the same permission when invalidated', async () => {
const permission = buildPermission('update', 'role:123');
let requestCount = 0;
let shouldGrant = true;
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [shouldGrant])),
);
}),
);
// Two separate hooks using the same permission
const { result: result1 } = renderHook(
() => {
const queryClient = useQueryClient();
const authz = useAuthZ([permission]);
return { authz, queryClient };
},
{ wrapper },
);
const { result: result2 } = renderHook(() => useAuthZ([permission]), {
wrapper,
});
await waitFor(() => {
expect(result1.current.authz.isLoading).toBe(false);
expect(result2.current.isLoading).toBe(false);
});
// Both should show granted, single batched request
expect(requestCount).toBe(1);
expect(result1.current.authz.allowed).toBe(true);
expect(result2.current.allowed).toBe(true);
// Change server response and reset query (forces refetch)
shouldGrant = false;
await act(async () => {
await result1.current.queryClient.resetQueries(['authz', permission]);
});
// Both hooks should update
await waitFor(() => {
expect(result1.current.authz.allowed).toBe(false);
expect(result2.current.allowed).toBe(false);
});
expect(result1.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
expect(result2.current.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
});
});

View File

@@ -1,15 +1,13 @@
import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { isAxiosError } from 'axios';
import { authzCheck } from 'api/generated/services/authz';
import type {
CoretypesObjectDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { IS_DEV, MODE } from 'lib/env';
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
import type {
import {
AuthZCheckResponse,
BrandedPermission,
UseAuthZOptions,
@@ -19,59 +17,6 @@ import {
gettableTransactionToPermission,
permissionToTransactionDto,
} from './utils';
import { OverrideState } from '../../devtools/types';
let devStoreRef:
| typeof import('../../devtools/useAuthZDevStore').useAuthZDevStore
| null = null;
if (IS_DEV) {
void import('../../devtools/useAuthZDevStore').then((mod) => {
devStoreRef = mod.useAuthZDevStore;
return mod;
});
}
const DEV_DELAY_MS = 2000;
function getDevOverride(permission: BrandedPermission): OverrideState | null {
if (!IS_DEV || !devStoreRef) {
return null;
}
return devStoreRef.getState().overrides[permission] ?? null;
}
async function applyDevOverrideToQuery(
permission: BrandedPermission,
fetchFn: () => Promise<AuthZCheckResponse>,
): Promise<AuthZCheckResponse> {
const override = getDevOverride(permission);
if (override === OverrideState.Error) {
throw new Error(`[AuthZ DevTools] Simulated error for: ${permission}`);
}
if (override === OverrideState.Delay) {
await new Promise((resolve) => setTimeout(resolve, DEV_DELAY_MS));
}
const response = await fetchFn();
if (IS_DEV && devStoreRef) {
const apiValue = response[permission]?.isGranted ?? false;
devStoreRef.getState().registerObserved(permission, apiValue);
}
if (override === OverrideState.Granted) {
return { [permission]: { isGranted: true } };
}
if (override === OverrideState.Denied) {
return { [permission]: { isGranted: false } };
}
return response;
}
let ctx: Promise<AuthZCheckResponse> | null;
let pendingPermissions: BrandedPermission[] = [];
@@ -82,11 +27,10 @@ function dispatchPermission(
pendingPermissions.push(permission);
if (!ctx) {
let promiseResolve: (v: AuthZCheckResponse) => void,
promiseReject: (reason?: unknown) => void;
ctx = new Promise<AuthZCheckResponse>((resolve, reject) => {
promiseResolve = resolve;
promiseReject = reject;
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
ctx = new Promise<AuthZCheckResponse>((r, re) => {
resolve = r;
reject = re;
});
setTimeout(() => {
@@ -94,9 +38,7 @@ function dispatchPermission(
pendingPermissions = [];
ctx = null;
fetchManyPermissions(copiedPermissions)
.then(promiseResolve)
.catch(promiseReject);
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
}, SINGLE_FLIGHT_WAIT_TIME_MS);
}
@@ -143,50 +85,19 @@ export function useAuthZ(
return {
queryKey: ['authz', permission],
cacheTime: AUTHZ_CACHE_TIME,
staleTime: AUTHZ_CACHE_TIME,
// Keep errored state in cache instead of refetching when new observers subscribe
retryOnMount: false,
// Only override retry in non-test mode to avoid interfering with test-utils QueryClient defaults
...(MODE !== 'test' && {
retry: (failureCount: number, error: unknown): boolean => {
// Don't retry simulated dev errors - they will always fail
if (
error instanceof Error &&
error.message.includes('[AuthZ DevTools]')
) {
return false;
}
// Don't retry server errors (5xx) - they won't recover
if (
isAxiosError(error) &&
error.response?.status &&
error.response.status >= 500
) {
return false;
}
return failureCount < 3;
},
}),
refetchOnMount: false,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
queryFn: async (): Promise<AuthZCheckResponse> => {
const fetchFn = async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
if (IS_DEV) {
return applyDevOverrideToQuery(permission, fetchFn);
}
return fetchFn();
},
};
}),
@@ -196,7 +107,6 @@ export function useAuthZ(
() => queryResults.some((q) => q.isLoading),
[queryResults],
);
const isFetching = useMemo(
() => queryResults.some((q) => q.isFetching),
[queryResults],
@@ -229,31 +139,15 @@ export function useAuthZ(
const refetchPermissions = useCallback(() => {
for (const query of queryResults) {
void query.refetch();
query.refetch();
}
}, [queryResults]);
const allowed = useMemo(() => {
if (isLoading || error || !data) {
return false;
}
return permissions.every((check) => data[check]?.isGranted === true);
}, [permissions, data, isLoading, error]);
const deniedPermissions = useMemo(() => {
if (!data) {
return [];
}
return permissions.filter((check) => data[check]?.isGranted !== true);
}, [permissions, data]);
return {
isLoading,
isFetching,
error,
permissions: data ?? null,
allowed,
deniedPermissions,
refetchPermissions,
};
}

View File

@@ -168,8 +168,6 @@ export function mockUseAuthZGrantAll(
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: true }]),
) as UseAuthZResult['permissions'],
allowed: true,
deniedPermissions: [],
refetchPermissions: jest.fn(),
};
}
@@ -185,8 +183,6 @@ export function mockUseAuthZDenyAll(
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
allowed: false,
deniedPermissions: permissions,
refetchPermissions: jest.fn(),
};
}
@@ -197,23 +193,16 @@ export function mockUseAuthZGrantByPrefix(
permissions: BrandedPermission[],
options?: UseAuthZOptions,
) => UseAuthZResult {
return (permissions, _options) => {
const denied = permissions.filter(
(p) => !prefixes.some((prefix) => p.startsWith(prefix)),
);
return {
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [
p,
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
]),
) as UseAuthZResult['permissions'],
allowed: denied.length === 0,
deniedPermissions: denied,
refetchPermissions: jest.fn(),
};
};
return (permissions, _options) => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [
p,
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
});
}

View File

@@ -1,3 +0,0 @@
export const IS_DEV = import.meta.env.DEV;
export const IS_PROD = import.meta.env.PROD;
export const MODE = import.meta.env.MODE;

View File

@@ -26,8 +26,8 @@ export default function AIAssistantPage(): JSX.Element {
// Skip the mount-time Opened fire when the user expanded an already-open
// drawer/modal — that surface already emitted Opened with the right source.
// Router state (vs a module flag) survives page remounts and aborted
// navigations.
// Router state (vs a module flag) survives StrictMode double-mount and
// aborted navigations.
const fromInApp = location.state?.fromInApp === true;
useEffect(() => {
if (fromInApp) {
@@ -52,34 +52,18 @@ export default function AIAssistantPage(): JSX.Element {
(s) => s.startNewConversation,
);
// Keep refs so the effect can read the latest store state without re-firing
// when it mutates the store mid-effect (it only depends on the URL param).
// Keep a ref so the effect can read latest conversations without re-firing
// when startNewConversation mutates the store mid-effect.
const conversationsRef = useRef(conversations);
conversationsRef.current = conversations;
const activeConversationIdRef = useRef(activeConversationId);
activeConversationIdRef.current = activeConversationId;
useEffect(() => {
// URL points at a known conversation → just activate it.
if (conversationId && conversationsRef.current[conversationId]) {
if (conversationsRef.current[conversationId]) {
setActiveConversation(conversationId);
return;
} else {
const newId = startNewConversation();
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
}
// The URL has no usable conversation id (bare `/ai-assistant`, or a stale
// param). Prefer resuming the active conversation — including the
// rehydrating placeholder for the persisted thread — over minting a new
// one. This is what stops a throwaway blank chat from flashing as a
// second thread during load, and stops a duplicate when the page
// remounts during startup route churn (the active id is already set, so
// we resume instead of create). Starting fresh is the last resort, only
// when there is genuinely nothing to resume.
const activeId = activeConversationIdRef.current;
const resumeId =
activeId && conversationsRef.current[activeId]
? activeId
: startNewConversation();
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', resumeId));
// Only re-run when the URL param changes, not when conversations mutates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId]);

View File

@@ -1,181 +0,0 @@
import { MemoryRouter, Route } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/ConversationView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="conversation-view" />,
}));
jest.mock('container/AIAssistant/components/ConversationsList', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="conversations-list" />,
}));
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ mode: 'page' }),
}));
// eslint-disable-next-line import/first
import AIAssistantPage from '../AIAssistantPage';
function renderAt(entry: string): { unmount: () => void } {
return render(
<MemoryRouter initialEntries={[entry]}>
<Route
exact
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
component={AIAssistantPage}
/>
</MemoryRouter>,
);
}
function renderAtBase(): { unmount: () => void } {
return renderAt(ROUTES.AI_ASSISTANT_BASE);
}
function conversationCount(): number {
return Object.keys(useAIAssistantStore.getState().conversations).length;
}
function conversationIds(): string[] {
return Object.keys(useAIAssistantStore.getState().conversations);
}
function activeId(): string | null {
return useAIAssistantStore.getState().activeConversationId;
}
describe('AIAssistantPage', () => {
beforeEach(() => {
useAIAssistantStore.setState({
conversations: {},
streams: {},
activeConversationId: null,
});
});
it('opens exactly one conversation when navigating to /ai-assistant', () => {
const { unmount } = renderAtBase();
expect(conversationCount()).toBe(1);
unmount();
});
it('does not stack a second conversation when the page remounts at the bare URL (route churn)', () => {
// First mount at `/ai-assistant` creates one blank conversation and
// redirects to `/ai-assistant/:id`.
const { unmount } = renderAtBase();
expect(conversationCount()).toBe(1);
const firstId = conversationIds()[0];
// Startup route-list churn unmounts and remounts the page while the URL
// is momentarily back at the bare `/ai-assistant`. This previously
// created a second blank conversation — now it reuses the first.
unmount();
const { unmount: unmount2 } = renderAtBase();
expect(conversationCount()).toBe(1);
// The surviving conversation is the original one, resumed — not a fresh mint.
expect(conversationIds()).toStrictEqual([firstId]);
expect(activeId()).toBe(firstId);
unmount2();
});
it('activates the conversation named in the URL without creating a new one', () => {
useAIAssistantStore.setState({
conversations: {
existing: {
id: 'existing',
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: null,
});
const { unmount } = renderAt(
ROUTES.AI_ASSISTANT.replace(':conversationId', 'existing'),
);
expect(conversationCount()).toBe(1);
expect(activeId()).toBe('existing');
unmount();
});
it('resumes the active conversation on /ai-assistant/new instead of minting a new one', () => {
// The sidenav only routes to `/ai-assistant/new` as a fallback, but if an
// active conversation exists the page must resume it rather than spawn a
// throwaway blank thread for the unknown "new" param.
useAIAssistantStore.setState({
conversations: {
active: {
id: 'active',
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: 'active',
});
const { unmount } = renderAt(
ROUTES.AI_ASSISTANT.replace(':conversationId', 'new'),
);
expect(conversationCount()).toBe(1);
expect(conversationIds()).toStrictEqual(['active']);
expect(activeId()).toBe('active');
unmount();
});
it('resumes the persisted (hydrating) conversation during load instead of creating a second', () => {
// Simulates `onRehydrateStorage` priming the persisted active
// conversation as a hydrating placeholder before `fetchThreads` resolves.
useAIAssistantStore.setState({
conversations: {
persisted: {
id: 'persisted',
messages: [],
createdAt: 1,
updatedAt: 1,
isHydrating: true,
},
},
streams: {},
activeConversationId: 'persisted',
});
const { unmount } = renderAtBase();
// Opening the bare URL must resume the persisted conversation, not mint a
// throwaway blank alongside it (which flashed as a 2nd thread during load).
expect(conversationCount()).toBe(1);
expect(
Object.keys(useAIAssistantStore.getState().conversations),
).toStrictEqual(['persisted']);
unmount();
});
});

View File

@@ -32,22 +32,6 @@
cursor: help;
}
.descriptionTooltip {
display: block;
overflow-wrap: anywhere;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.publicLink {
display: inline-flex;
align-items: center;

View File

@@ -14,7 +14,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { linkifyText } from 'utils/linkifyText';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
@@ -144,14 +143,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple
side="bottom"
title={
<span className={styles.descriptionTooltip}>
{linkifyText(description)}
</span>
}
>
<TooltipSimple title={description} disableHoverableContent>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}

View File

@@ -48,13 +48,6 @@
gap: 12px;
}
.footerStatus {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.validation {
min-width: 0;
overflow: hidden;
@@ -64,32 +57,6 @@
white-space: nowrap;
}
.danglingWarning {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
color: var(--bg-amber-400);
font-size: 12px;
}
.warningIcon {
flex: none;
}
.warningText {
overflow: hidden;
color: var(--bg-amber-400);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
// Above the JSON drawer (antd Drawer sits at ~1100) so the id list is visible.
.warningTooltip {
z-index: 1101;
}
.validationValid {
color: var(--bg-forest-400);
}

View File

@@ -1,8 +1,6 @@
import { KeyboardEvent, useCallback } from 'react';
import MEditor from '@monaco-editor/react';
import { TriangleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
@@ -28,18 +26,8 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const {
draft,
setDraft,
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -60,7 +48,6 @@ function JsonEditorDrawer({
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
@@ -73,19 +60,6 @@ function JsonEditorDrawer({
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
const plural = (n: number): string => (n === 1 ? '' : 's');
const danglingWarning =
danglingPanelIds.length > 0
? `${danglingPanelIds.length} panel${plural(
danglingPanelIds.length,
)} not present in layout — they won't be shown after saving.`
: null;
const missingRefWarning =
missingPanelRefs.length > 0
? `${missingPanelRefs.length} layout item${plural(
missingPanelRefs.length,
)} ${missingPanelRefs.length === 1 ? 'references' : 'reference'} a panel that no longer exists.`
: null;
return (
<Drawer
@@ -97,49 +71,15 @@ function JsonEditorDrawer({
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<div className={styles.footerStatus}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
{danglingWarning && (
<TooltipSimple
title={danglingPanelIds.join(', ')}
tooltipContentProps={{ className: styles.warningTooltip }}
>
<span
className={styles.danglingWarning}
data-testid="json-editor-dangling-warning"
>
<TriangleAlert size={12} className={styles.warningIcon} />
<Typography.Text className={styles.warningText}>
{danglingWarning}
</Typography.Text>
</span>
</TooltipSimple>
)}
{missingRefWarning && (
<TooltipSimple
title={missingPanelRefs.join(', ')}
tooltipContentProps={{ className: styles.warningTooltip }}
>
<span
className={styles.danglingWarning}
data-testid="json-editor-missing-ref-warning"
>
<TriangleAlert size={12} className={styles.warningIcon} />
<Typography.Text className={styles.warningText}>
{missingRefWarning}
</Typography.Text>
</span>
</TooltipSimple>
)}
</div>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerActions}>
<Button
variant="outlined"

View File

@@ -1,5 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
@@ -49,13 +48,11 @@ function hookValue(
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
danglingPanelIds: [],
missingPanelRefs: [],
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
} as ReturnType<typeof useJsonEditor>;
};
}
describe('JsonEditorDrawer', () => {
@@ -84,42 +81,6 @@ describe('JsonEditorDrawer', () => {
);
});
it('warns about dangling panels, and hides the warning when there are none', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
);
const { rerender } = render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
'2 panels not present in layout',
);
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
rerender(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(
screen.queryByTestId('json-editor-dangling-warning'),
).not.toBeInTheDocument();
});
it('warns about layout refs to missing panels', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(
screen.getByTestId('json-editor-missing-ref-warning'),
).toHaveTextContent('1 layout item references a panel that no longer exists');
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({

View File

@@ -1,69 +0,0 @@
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { findPanelLayoutIssues } from '../danglingPanels';
const grid = (refs: string[]): unknown => ({
kind: 'Grid',
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
});
// Cast a loose fixture to the spec type — the helper is defensive against the
// untrusted, hand-edited JSON it runs on.
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
value as DashboardtypesDashboardSpecDTO;
describe('findPanelLayoutIssues', () => {
it('flags nothing when panels and layouts agree', () => {
expect(
findPanelLayoutIssues(
spec({
panels: { a: {}, b: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
}),
),
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
});
it('lists panels placed in no layout as dangling', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {}, b: {}, c: {} },
layouts: [grid(['#/spec/panels/a'])],
}),
);
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
expect(result.missingPanelRefs).toStrictEqual([]);
});
it('treats a removed/empty layout as orphaning every panel', () => {
expect(
findPanelLayoutIssues(
spec({ panels: { a: {}, b: {} }, layouts: [] }),
).danglingPanelIds.sort(),
).toStrictEqual(['a', 'b']);
});
it('lists layout refs to a panel that no longer exists as missing', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
}),
);
expect(result.danglingPanelIds).toStrictEqual([]);
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
});
it('handles empty / malformed specs', () => {
expect(
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
});
});

View File

@@ -203,47 +203,4 @@ describe('useJsonEditor', () => {
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
it('reports panels not placed in any layout as dangling', () => {
const withDangling = {
...dashboard,
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withDangling,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
expect(result.current.missingPanelRefs).toStrictEqual([]);
});
it('reports layout refs to panels that no longer exist as missing', () => {
const withMissing = {
...dashboard,
spec: {
...dashboard.spec,
panels: {},
layouts: [
{
kind: 'Grid',
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
},
],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withMissing,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
expect(result.current.danglingPanelIds).toStrictEqual([]);
});
});

View File

@@ -1,47 +0,0 @@
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { extractPanelIdFromRef } from '../../utils';
export interface PanelLayoutIssues {
// Panels defined in `spec.panels` that no layout places — they render nowhere.
danglingPanelIds: string[];
// Panel ids a layout item references that no longer exist in `spec.panels`.
missingPanelRefs: string[];
}
const referencedPanelIds = (
layouts: DashboardtypesLayoutDTO[],
): Set<string> => {
const referenced = new Set<string>();
layouts.forEach((layout) => {
if (layout?.kind !== 'Grid') {
return;
}
(layout.spec?.items ?? []).forEach((item) => {
const id = extractPanelIdFromRef(item?.content?.$ref);
if (id) {
referenced.add(id);
}
});
});
return referenced;
};
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
// removed (broken reference). Guarded for untrusted, user-edited JSON.
export function findPanelLayoutIssues(
spec: DashboardtypesDashboardSpecDTO | undefined,
): PanelLayoutIssues {
const panels = spec?.panels ?? {};
const panelIds = Object.keys(panels);
const referenced = referencedPanelIds(spec?.layouts ?? []);
return {
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
};
}

View File

@@ -1,15 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toAPIError } from 'utils/errorUtils';
import APIError from 'types/api/error';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -32,10 +28,6 @@ interface Result {
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
danglingPanelIds: string[];
// Panel ids a layout references that are missing from the draft's `spec.panels`.
missingPanelRefs: string[];
format: () => void;
reset: () => void;
apply: () => Promise<void>;
@@ -117,23 +109,6 @@ export function useJsonEditor({
const isDirty = draft !== appliedText;
const { danglingPanelIds, missingPanelRefs } = useMemo<{
danglingPanelIds: string[];
missingPanelRefs: string[];
}>(() => {
if (!validity.valid) {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
try {
const parsed = JSON.parse(draft) as {
spec?: DashboardtypesDashboardSpecDTO;
};
return findPanelLayoutIssues(parsed.spec);
} catch {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
}, [draft, validity.valid]);
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
@@ -163,7 +138,7 @@ export function useJsonEditor({
refetch();
onApplied();
} catch (error) {
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
@@ -184,8 +159,6 @@ export function useJsonEditor({
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,

View File

@@ -63,13 +63,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -188,7 +183,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -55,32 +54,20 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const op = (
operation: DashboardtypesJSONPatchOperationDTO['op'],
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({ op: operation, path, value });
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO =>
op(DashboardtypesPatchOpDTO.replace, path, value);
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title && updatedTitle !== '') {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
// `replace` fails when the description doesn't exist yet, so add it when
// the current one is empty (`add` creates or replaces the member).
ops.push(
op(
description
? DashboardtypesPatchOpDTO.replace
: DashboardtypesPatchOpDTO.add,
'/spec/display/description',
updatedDescription,
),
);
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));

View File

@@ -1,69 +1,22 @@
.panelTypeSection {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.panelTypeCard {
.typeButton {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--l2-border);
background: var(--l2-background);
gap: 8px;
padding: 12px;
gap: 12px;
cursor: pointer;
font: inherit;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
border-radius: 4px;
color: var(--l1-foreground);
transition:
transform 180ms ease,
border-color 180ms ease;
cursor: pointer;
text-align: left;
&:hover {
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
border-color: var(--bg-robin-500);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -1,93 +1,45 @@
import { useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
/** Section the picker opens on; omit → the first section. */
defaultLayoutIndex?: number;
onSelect: (panelKind: PanelKind) => void;
}
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
defaultLayoutIndex,
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
// Seed the target section on open.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
}
}, [open, options, defaultLayoutIndex]);
const handleConfirm = (): void => {
if (selectedPanelKind === null) {
return;
}
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
onSelect(selectedPanelKind, layoutIndex);
};
return (
<DialogWrapper
<Modal
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="New Panel"
footer={
<PanelTypeSelectionModalFooter
options={options}
selectedValue={selectedValue}
onSectionChange={setSelectedValue}
isConfirmDisabled={selectedPanelKind === null}
onConfirm={handleConfirm}
/>
}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.panelTypeSection}>
<span className={styles.pickerLabel}>Select panel type</span>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => setSelectedPanelKind(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</button>
))}
</div>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
{label}
</Button>
))}
</div>
</DialogWrapper>
</Modal>
);
}

View File

@@ -1,56 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import SectionPicker from './SectionPicker';
import type { SectionOption } from './types';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalFooterProps {
options: SectionOption[];
selectedValue: string;
onSectionChange: (value: string) => void;
/** Disabled until a panel type is picked. */
isConfirmDisabled: boolean;
onConfirm: () => void;
}
/**
* Footer for the New Panel modal: an "Add panel to" section picker (shown only
* when the dashboard has more than one section) and the confirm button.
*/
function PanelTypeSelectionModalFooter({
options,
selectedValue,
onSectionChange,
isConfirmDisabled,
onConfirm,
}: PanelTypeSelectionModalFooterProps): JSX.Element {
const hasSectionPicker = options.length > 1;
return (
<div className={styles.footerActions}>
{hasSectionPicker && (
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={onSectionChange}
/>
</div>
)}
<Button
color="primary"
size="md"
disabled={isConfirmDisabled}
prefix={<Plus size={16} />}
onClick={onConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
}
export default PanelTypeSelectionModalFooter;

View File

@@ -1,55 +0,0 @@
.select {
width: 100%;
}
.dropdown {
min-width: 260px;
}
.rootIcon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
.sectionIcon {
color: var(--l3-foreground);
flex-shrink: 0;
}
.triggerValue {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.optionText {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.optionLabel {
color: var(--l1-foreground);
line-height: 1.2;
}
.optionDescription {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.2;
}

View File

@@ -1,65 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
import type { SectionOption } from './types';
import styles from './SectionPicker.module.scss';
interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
}
function SectionPicker({
options,
value,
onChange,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
() =>
options.map((option) => {
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
return {
value: option.value,
selectedLabel: (
<span className={styles.triggerValue}>
<option.Icon size={16} className={iconClass} />
<span className={styles.triggerLabel}>{option.label}</span>
</span>
),
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
<span className={styles.optionLabel}>{option.label}</span>
<span className={styles.optionDescription}>{option.description}</span>
</span>
</span>
),
};
}),
[options],
);
return (
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
value={value}
onChange={onChange}
data-testid="panel-section-select"
optionLabelProp="selectedLabel"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
options={selectOptions}
/>
);
}
export default SectionPicker;

View File

@@ -14,16 +14,3 @@ export interface PanelType {
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;
layoutIndex: number;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */
description: string;
/** Untitled top-level layout (has no section header). */
isRoot: boolean;
Icon: ComponentType<IconProps>;
}

View File

@@ -1,41 +0,0 @@
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
import type { DashboardSection } from '../../../utils';
import type { SectionOption } from './types';
const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
return sections.map((section) => {
const isRoot = !section.title && section.layoutIndex === 0;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}
const target = String(defaultLayoutIndex);
return options.some((option) => option.value === target) ? target : fallback;
}

View File

@@ -29,13 +29,8 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
@@ -146,7 +141,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}

View File

@@ -12,10 +12,7 @@ interface UseCreatePanelResult {
/** Pass the target section's layout index; omit → last/new section. */
openPicker: (layoutIndex?: number) => void;
closePicker: () => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
createPanel: (panelKind: PanelKind) => void;
}
/**
@@ -41,23 +38,16 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
(panelKind: PanelKind): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
},
[safeNavigate, dashboardId, layoutIndex],
);
return {
isPickerOpen,
openPicker,
closePicker,
targetLayoutIndex: layoutIndex,
createPanel,
};
return { isPickerOpen, openPicker, closePicker, createPanel };
}

View File

@@ -1,18 +0,0 @@
import { useMemo } from 'react';
import { type DashboardSection, layoutsToSections } from '../utils';
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
/**
* The current dashboard's sections, derived from the guaranteed-loaded dashboard
* via useDashboardFetchRequired. That reuses the shared react-query cache (no extra
* request) instead of prop-drilling the section list.
*/
export function useDashboardSections(): DashboardSection[] {
const { dashboard } = useDashboardFetchRequired();
const spec = dashboard.spec;
return useMemo(
() => layoutsToSections(spec.layouts, spec.panels),
[spec.layouts, spec.panels],
);
}

View File

@@ -7,7 +7,6 @@ import styles from './DashboardsList.module.scss';
interface Props {
label: string;
count: number;
isModified: boolean;
canCreate: boolean;
onCreate: () => void;
}
@@ -15,7 +14,6 @@ interface Props {
function CommandHeader({
label,
count,
isModified,
canCreate,
onCreate,
}: Props): JSX.Element {
@@ -23,7 +21,6 @@ function CommandHeader({
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>{label}</Typography.Title>
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
<span className={styles.countPill}>{count}</span>
</div>
<div className={styles.grow} />

View File

@@ -93,14 +93,6 @@
flex: 1;
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning-background);
flex: none;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;

View File

@@ -10,9 +10,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { toAPIError } from 'utils/errorUtils';
import { combineQueries } from '../../utils/filterQuery';
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
import {
usePage,
@@ -25,6 +25,7 @@ import { BuiltinViewId } from '../../types';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import type { CreatorOption } from '../FilterZone/FilterChips';
import FilterZone from '../FilterZone/FilterZone';
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
import StatusBar from '../StatusBar/StatusBar';
@@ -71,13 +72,13 @@ function DashboardsList(): JSX.Element {
customViewsLoading,
isCustomActive,
isModified,
viewQuery,
clientView,
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
} = useActiveView({
filters,
applyFilters,
@@ -152,13 +153,13 @@ function DashboardsList(): JSX.Element {
const listParams = useMemo(
() => ({
query: query || undefined,
query: combineQueries(viewQuery, query) || undefined,
sort: sortColumn,
order: sortOrder,
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
}),
[query, sortColumn, sortOrder, page, clientView],
[viewQuery, query, sortColumn, sortOrder, page, clientView],
);
const {
@@ -194,19 +195,24 @@ function DashboardsList(): JSX.Element {
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
// Authors present on the loaded page — a fallback for the creator filter until
// the org-wide user list resolves.
const pageAuthorEmails = useMemo<string[]>(
() =>
rawDashboards
.map((d) => d.createdBy)
.filter((email): email is string => !!email),
[rawDashboards],
);
const creatorOptions = useCreatorOptions({
currentUserEmail: user.email,
fallbackEmails: pageAuthorEmails,
});
// Creator filter options: distinct authors on the loaded page plus the
// current user (so "me" is always selectable). Page-scoped until a members
// source backs this.
const creatorOptions = useMemo<CreatorOption[]>(() => {
const emails = new Set<string>();
if (user.email) {
emails.add(user.email);
}
rawDashboards.forEach((d) => {
if (d.createdBy) {
emails.add(d.createdBy);
}
});
return [...emails].sort().map((email) => ({
email,
label: email === user.email ? `${email} (me)` : email,
}));
}, [rawDashboards, user.email]);
// All key:value tags the API reports for the org's dashboards, powering the
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
@@ -283,8 +289,8 @@ function DashboardsList(): JSX.Element {
onSave={saveView}
onSaveChanges={saveActiveView}
onReset={handleResetView}
onClearFilters={handleClearAll}
onDelete={handleRemoveView}
onRename={renameView}
/>
<div className={styles.main}>
<div className={styles.mainScroll}>
@@ -299,7 +305,6 @@ function DashboardsList(): JSX.Element {
<CommandHeader
label={activeLabel}
count={total}
isModified={isModified}
canCreate={canCreateNewDashboard}
onCreate={openCreate}
/>

View File

@@ -6,45 +6,32 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './ViewsRail.module.scss';
export const VIEW_NAME_MAX_LENGTH = 128;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (name: string) => void;
onSave: (name: string) => void;
trigger: ReactNode;
title: string;
confirmLabel: string;
initialName?: string;
testIdPrefix?: string;
}
// Name-input popover shared by "save as view" and "rename view"; enforces the
// view-name length cap in one place.
function ViewNamePopover({
function SaveViewPopover({
open,
onOpenChange,
onSubmit,
onSave,
trigger,
title,
confirmLabel,
initialName = '',
testIdPrefix = 'view-name',
}: Props): JSX.Element {
const [name, setName] = useState(initialName);
const [name, setName] = useState('');
useEffect(() => {
if (open) {
setName(initialName);
setName('');
}
}, [open, initialName]);
}, [open]);
const trimmed = name.trim();
const canSave = trimmed.length > 0 && trimmed.length <= VIEW_NAME_MAX_LENGTH;
const canSave = name.trim().length > 0;
const handleSave = (): void => {
if (canSave) {
onSubmit(trimmed);
onSave(name);
onOpenChange(false);
}
};
@@ -57,14 +44,13 @@ function ViewNamePopover({
trigger={trigger}
>
<div className={styles.savePopover}>
<div className={styles.saveTitle}>{title}</div>
<div className={styles.saveTitle}>Save as view</div>
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
<Input
value={name}
autoFocus
maxLength={VIEW_NAME_MAX_LENGTH}
placeholder="e.g. Prod alerts"
testId={`${testIdPrefix}-name`}
testId="save-view-name"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
@@ -88,10 +74,10 @@ function ViewNamePopover({
color="primary"
size="sm"
disabled={!canSave}
testId={`${testIdPrefix}-confirm`}
testId="save-view-confirm"
onClick={handleSave}
>
{confirmLabel}
Save view
</Button>
</div>
</div>
@@ -99,4 +85,4 @@ function ViewNamePopover({
);
}
export default ViewNamePopover;
export default SaveViewPopover;

View File

@@ -145,40 +145,30 @@
flex: none;
}
// Ghost action icons overlaid on the row's right edge — absolutely positioned so
// they never reserve layout space or affect row height. Hidden until row hover,
// and inert while hidden so they can't intercept clicks meant for the row.
.itemActions {
.itemAction {
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
// so it never reserves layout space or affects the row height. Transparent so
// the row's hover background shows through; turns red only on its own hover.
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s;
}
.itemAction {
--button-height: 20px;
--button-padding: 0;
--button-border-radius: 4px;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-background-color: var(--l1-background);
--button-variant-ghost-hover-color: var(--l1-foreground);
width: 20px;
height: 20px;
flex: none;
}
.itemActionDanger {
--button-variant-ghost-hover-background-color: var(--danger-background);
--button-variant-ghost-hover-color: var(--danger-color, #fff);
width: 20px;
height: 20px;
opacity: 0;
// Hidden until row hover, and inert while hidden so it can't intercept clicks
// meant for the row.
pointer-events: none;
transition: opacity 0.1s;
}
.row:hover .itemActions {
.row:hover .itemAction {
opacity: 1;
pointer-events: auto;
}

View File

@@ -3,19 +3,12 @@ import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import {
Bookmark,
CircleAlert,
PenLine,
Plus,
Search,
Trash2,
} from '@signozhq/icons';
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import type { SavedView } from '../../types';
import { type BuiltinView } from '../../utils/views';
import ViewNamePopover from './ViewNamePopover';
import SaveViewPopover from './SaveViewPopover';
import styles from './ViewsRail.module.scss';
@@ -31,8 +24,8 @@ interface Props {
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onClearFilters: () => void;
onDelete: (id: string) => void;
onRename: (id: string, name: string) => void;
}
interface ViewRow {
@@ -56,11 +49,10 @@ function ViewsRail({
onSave,
onSaveChanges,
onReset,
onClearFilters,
onDelete,
onRename,
}: Props): JSX.Element {
const [saveOpen, setSaveOpen] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [modal, contextHolder] = Modal.useModal();
@@ -130,44 +122,20 @@ function ViewsRail({
)}
</Button>
{row.deletable && (
<div className={styles.itemActions}>
<ViewNamePopover
open={renamingId === row.id}
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
onSubmit={(name): void => onRename(row.id, name)}
title="Rename view"
confirmLabel="Rename"
initialName={row.label}
testIdPrefix="rename-view"
trigger={
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.itemAction}
aria-label="Rename view"
title="Rename view"
onClick={(e): void => e.stopPropagation()}
>
<PenLine size={12} />
</Button>
}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.itemAction, styles.itemActionDanger)}
aria-label="Delete view"
title="Delete view"
onClick={(e): void => {
e.stopPropagation();
confirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
</div>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.itemAction}
aria-label="Delete view"
title="Delete view"
onClick={(e): void => {
e.stopPropagation();
confirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
)}
</div>
);
@@ -177,13 +145,10 @@ function ViewsRail({
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<ViewNamePopover
<SaveViewPopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={onSave}
title="Save as view"
confirmLabel="Save view"
testIdPrefix="save-view"
onSave={onSave}
trigger={
<Button
variant="ghost"
@@ -303,8 +268,13 @@ function ViewsRail({
>
Save as new view
</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onClearFilters}
>
Clear
</Button>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import type {
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
@@ -14,6 +15,7 @@ import { BuiltinViewId } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import {
BUILTIN_VIEWS,
builtinViewQuery,
builtinViewSnapshot,
type BuiltinView,
isClientView,
@@ -40,14 +42,15 @@ export interface UseActiveViewResult {
isCustomActive: boolean;
// Current filters diverge from the active view's canonical snapshot.
isModified: boolean;
// Whether the active view constrains the list client-side (pinned/recent).
// Extra server-query fragment the active view contributes, and whether it
// constrains the list client-side (pinned/recent).
viewQuery: string;
clientView: boolean;
selectView: (id: string) => void;
saveView: (name: string) => void;
saveActiveView: () => void;
resetView: () => void;
removeView: (id: string) => void;
renameView: (id: string, name: string) => void;
}
// The canonical filter snapshot a saved view "is": the backend stores a flat
@@ -124,9 +127,11 @@ export function useActiveView({
const saveView = useCallback(
(name: string): void => {
// The active view's clause already lives in the filter state (e.g. Locked
// seeds `locked = true` into search), so the chips fold into one query.
const query = filterStateToQuery(filters);
// Fold the current built-in clause + chips into a single query string.
const query = combineQueries(
builtinViewQuery(activeViewId),
filterStateToQuery(filters),
);
void (async (): Promise<void> => {
const created = await createView({
name,
@@ -142,7 +147,15 @@ export function useActiveView({
}
})();
},
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
[
activeViewId,
filters,
createView,
sortColumn,
sortOrder,
setActiveViewId,
applyFilters,
],
);
const saveActiveView = useCallback((): void => {
@@ -187,23 +200,6 @@ export function useActiveView({
[deleteView, activeViewId, setActiveViewId, applyFilters],
);
// Rename only touches the view's name; its stored query/sort/order are preserved.
const renameView = useCallback(
(id: string, name: string): void => {
const view = customViews.find((v) => v.id === id);
if (!view) {
return;
}
updateView(id, {
name,
query: view.query,
sort: view.sort,
order: view.order,
});
},
[customViews, updateView],
);
return {
activeViewId,
builtinViews: BUILTIN_VIEWS,
@@ -211,12 +207,12 @@ export function useActiveView({
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
viewQuery: builtinViewQuery(activeViewId),
clientView: isClientView(activeViewId),
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
};
}

View File

@@ -1,43 +0,0 @@
import { useMemo } from 'react';
import { useListUsers } from 'api/generated/services/users';
import type { CreatorOption } from '../components/FilterZone/FilterChips';
interface Args {
currentUserEmail: string;
// Authors on the loaded page — kept selectable until the org list resolves.
fallbackEmails: string[];
}
// Creator-filter options sourced from the org's full user list, so authors who
// aren't on the current page are still selectable (v2 "List users" API).
export function useCreatorOptions({
currentUserEmail,
fallbackEmails,
}: Args): CreatorOption[] {
const { data } = useListUsers();
return useMemo<CreatorOption[]>(() => {
const users = data?.data ?? [];
const emails = new Set<string>();
if (currentUserEmail) {
emails.add(currentUserEmail);
}
users.forEach((u) => u.email && emails.add(u.email));
// Until the org list resolves (or if it comes back empty), keep the page's
// authors selectable so the filter never regresses to just "me".
if (users.length === 0) {
fallbackEmails.forEach((e) => emails.add(e));
}
const labelFor = (email: string): string => {
if (email === currentUserEmail) {
return `${email} (me)`;
}
const match = users.find((u) => u.email === email);
return match?.displayName ? `${match.displayName} (${email})` : email;
};
return [...emails].sort().map((email) => ({ email, label: labelFor(email) }));
}, [data, currentUserEmail, fallbackEmails]);
}

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
getListDashboardViewsQueryKey,
invalidateListDashboardViews,
useCreateDashboardView,
useDeleteDashboardView,
@@ -13,7 +12,6 @@ import {
type DashboardtypesDashboardViewDTO,
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
type ListDashboardViews200,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedView, SavedViewInput } from '../types';
@@ -72,27 +70,9 @@ export function useSavedViews(): UseSavedViewsResult {
},
},
});
// Rename/save-changes returns the updated view, so patch it into the cached
// list inline instead of refetching the whole list.
const updateMutation = useUpdateDashboardView({
mutation: {
onSuccess: (response): void => {
const updated = response?.data;
const key = getListDashboardViewsQueryKey();
const prev = queryClient.getQueryData<ListDashboardViews200>(key);
if (!updated || !prev) {
return;
}
queryClient.setQueryData<ListDashboardViews200>(key, {
...prev,
data: {
...prev.data,
views: (prev.data.views ?? []).map((v) =>
v.id === updated.id ? updated : v,
),
},
});
},
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to update view.');
},

View File

@@ -1,7 +1,7 @@
// Built-in view catalogue + the pure logic that maps a view to how it
// constrains the list. Views fall into two mechanisms:
// - snapshot: selecting applies a filter snapshot — some seed the search box
// with DSL (My dashboards → created_by; Locked → locked = true)
// constrains the list. Views fall into three mechanisms:
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
// - query: contributes an extra server clause AND-ed with the chips (Locked)
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
@@ -49,9 +49,9 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
export const isClientView = (id: string): boolean =>
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
// DSL the Locked view seeds into the search box, so the constraint is visible
// (and editable) rather than applied invisibly behind the scenes.
export const LOCKED_QUERY = 'locked = true';
// Extra server query fragment a built-in view contributes (AND-ed with chips).
export const builtinViewQuery = (id: string): string =>
id === BuiltinViewId.Locked ? 'locked = true' : '';
// The canonical filter snapshot a built-in view applies when selected. `null`
// for ids that aren't built-in (custom views carry their own snapshot).
@@ -65,11 +65,10 @@ export const builtinViewSnapshot = (
...DEFAULT_FILTER_STATE,
createdBy: userEmail ? [userEmail] : [],
};
case BuiltinViewId.Locked:
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
case BuiltinViewId.All:
case BuiltinViewId.Pinned:
case BuiltinViewId.Recent:
case BuiltinViewId.Locked:
return { ...DEFAULT_FILTER_STATE };
default:
return null;

View File

@@ -33,19 +33,15 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
.resizable-box__content {
display: flex;
flex-direction: column;
overflow: visible;
}
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
@@ -54,9 +50,7 @@
}
.log-module-right-section {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -26,8 +26,6 @@ import {
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { defaultTo, isEmpty, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { EventSourceProvider } from 'providers/EventSource';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
@@ -46,23 +44,9 @@ import { ExplorerViews } from './utils';
import './LogsExplorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function LogsExplorer(): JSX.Element {
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
@@ -242,25 +226,14 @@ function LogsExplorer(): JSX.Element {
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="log-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</ResizableBox>
</section>
)}
<section className={cx('log-module-right-section')}>
<Toolbar

View File

@@ -18,17 +18,6 @@
z-index: 10;
background: var(--l2-border);
// Extend the interactive area beyond the 1px visual line so the handle
// is easy to grab and double-click, without changing its appearance.
&::before {
content: '';
position: absolute;
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
}
&:hover,
&:active {
background: var(--primary);
@@ -66,29 +55,4 @@
right: 0;
}
}
// Visible grip indicator (opt-in via the `withHandle` prop). Purely visual —
// pointer events fall through to the handle so it still owns drag + reset.
&__grip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 26px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l1-background);
color: var(--l2-foreground);
pointer-events: none;
}
&__handle:hover &__grip,
&__handle:active &__grip {
border-color: var(--primary);
color: var(--primary);
}
}

View File

@@ -1,4 +1,3 @@
import { GripVertical } from '@signozhq/icons';
import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
@@ -12,28 +11,15 @@ export interface ResizableBoxProps {
// resize (width). Dragging the handle away from the content grows the box;
// dragging it toward the content shrinks it.
handle?: ResizableBoxHandle;
// Canonical default size, and the target that double-click reset restores to.
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
// Starting size when different from the default (e.g. a persisted value).
// Falls back to defaultWidth/defaultHeight when omitted, preserving the
// behavior of callers that don't opt in.
initialWidth?: number;
initialHeight?: number;
// When true, double-clicking the handle resets the size to
// defaultWidth/defaultHeight and fires onResize with that value.
resetToDefaultOnDoubleClick?: boolean;
// When true, renders a visible grip indicator on the handle so it is
// discoverable as a draggable affordance.
withHandle?: boolean;
onResize?: (size: number) => void;
disabled?: boolean;
className?: string;
handleTestId?: string;
}
function ResizableBox({
@@ -45,22 +31,13 @@ function ResizableBox({
defaultWidth = 200,
minWidth = 50,
maxWidth = Infinity,
initialWidth,
initialHeight,
resetToDefaultOnDoubleClick = false,
withHandle = false,
onResize,
disabled = false,
className,
handleTestId,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = handle === 'left' || handle === 'right';
const isStartHandle = handle === 'top' || handle === 'left';
const [size, setSize] = useState(
isHorizontal
? (initialWidth ?? defaultWidth)
: (initialHeight ?? defaultHeight),
);
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(
@@ -106,21 +83,6 @@ function ResizableBox({
],
);
const handleDoubleClick = useCallback((): void => {
if (!resetToDefaultOnDoubleClick) {
return;
}
const nextSize = isHorizontal ? defaultWidth : defaultHeight;
setSize(nextSize);
onResize?.(nextSize);
}, [
resetToDefaultOnDoubleClick,
isHorizontal,
defaultWidth,
defaultHeight,
onResize,
]);
const containerStyle = disabled
? undefined
: isHorizontal
@@ -137,22 +99,7 @@ function ResizableBox({
style={containerStyle}
>
<div className="resizable-box__content">{children}</div>
{!disabled && (
<div
role="separator"
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
className={handleClass}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
data-testid={handleTestId}
>
{withHandle && (
<span className="resizable-box__grip">
<GripVertical size={12} />
</span>
)}
</div>
)}
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
</div>
);
}

View File

@@ -1,137 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import ResizableBox from '../ResizableBox';
const HANDLE_TEST_ID = 'resize-handle';
describe('ResizableBox', () => {
it('starts at defaultWidth when initialWidth is omitted', () => {
render(
<ResizableBox
handle="right"
defaultWidth={260}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
expect(box.style.width).toBe('260px');
});
it('starts at initialWidth when provided', () => {
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={340}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
expect(box.style.width).toBe('340px');
});
it('resets to defaultWidth and fires onResize on double-click when enabled', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={480}
onResize={onResize}
resetToDefaultOnDoubleClick
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
expect(box.style.width).toBe('480px');
fireEvent.doubleClick(handle);
expect(box.style.width).toBe('260px');
expect(onResize).toHaveBeenCalledWith(260);
});
it('does nothing on double-click when reset is not enabled', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={480}
onResize={onResize}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
fireEvent.doubleClick(handle);
expect(box.style.width).toBe('480px');
expect(onResize).not.toHaveBeenCalled();
});
it('renders a visible grip only when withHandle is set', () => {
const { rerender, container } = render(
<ResizableBox
handle="right"
defaultWidth={260}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
expect(container.querySelector('.resizable-box__grip')).toBeNull();
rerender(
<ResizableBox
handle="right"
defaultWidth={260}
withHandle
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
expect(container.querySelector('.resizable-box__grip')).not.toBeNull();
});
it('clamps drag to maxWidth and reports the clamped size via onResize', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
minWidth={240}
maxWidth={500}
onResize={onResize}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
fireEvent.mouseDown(handle, { clientX: 0 });
fireEvent.mouseMove(document, { clientX: 1000 });
fireEvent.mouseUp(document);
expect(box.style.width).toBe('500px');
expect(onResize).toHaveBeenLastCalledWith(500);
});
});

View File

@@ -1,89 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import usePanelWidth from '../usePanelWidth';
jest.mock('api/browser/localstorage/get');
jest.mock('api/browser/localstorage/set');
const mockedGet = getLocalStorageKey as jest.MockedFunction<
typeof getLocalStorageKey
>;
const mockedSet = setLocalStorageKey as jest.MockedFunction<
typeof setLocalStorageKey
>;
const ARGS = {
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
defaultWidth: 260,
minWidth: 240,
maxWidth: 500,
};
describe('usePanelWidth', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('returns defaultWidth when nothing is persisted', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(260);
});
it('returns the persisted width when present', () => {
mockedGet.mockReturnValue('340');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(340);
});
it('clamps an out-of-bounds persisted width on read', () => {
mockedGet.mockReturnValue('9999');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(500);
});
it('falls back to defaultWidth for an invalid persisted value', () => {
mockedGet.mockReturnValue('not-a-number');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(260);
});
it('persists a clamped width (debounced)', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
act(() => {
result.current.persistWidth(320);
jest.advanceTimersByTime(200);
});
expect(mockedSet).toHaveBeenCalledWith(
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
'320',
);
});
it('clamps below-min widths before persisting', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
act(() => {
result.current.persistWidth(10);
jest.advanceTimersByTime(200);
});
expect(mockedSet).toHaveBeenCalledWith(
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
'240',
);
});
});

View File

@@ -1,68 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import debounce from 'lodash-es/debounce';
import { useCallback, useMemo, useRef } from 'react';
const PERSIST_DEBOUNCE_MS = 150;
interface UsePanelWidthArgs {
/** Per-page localStorage key the width is persisted under. */
storageKey: LOCALSTORAGE;
/** Canonical default width, used when nothing is persisted. */
defaultWidth: number;
minWidth: number;
maxWidth: number;
}
interface UsePanelWidthReturn {
/** Width to start from: the persisted value (clamped) or the default. */
initialWidth: number;
/** Clamp and persist a width. Debounced to avoid a write per mousemove. */
persistWidth: (width: number) => void;
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
/**
* Per-page localStorage persistence for a resizable panel width. Mirrors the
* getLocalStorageKey/setLocalStorageKey idiom used for the trace span-details
* panel position. Pairs with ResizableBox: feed initialWidth into its
* initialWidth prop and persistWidth into its onResize.
*/
function usePanelWidth({
storageKey,
defaultWidth,
minWidth,
maxWidth,
}: UsePanelWidthArgs): UsePanelWidthReturn {
// Read once on mount. Kept in a ref so a re-render doesn't re-read storage.
const initialWidthRef = useRef<number | null>(null);
if (initialWidthRef.current === null) {
const stored = getLocalStorageKey(storageKey);
const parsed = stored !== null && stored !== '' ? Number(stored) : NaN;
initialWidthRef.current = Number.isFinite(parsed)
? clamp(parsed, minWidth, maxWidth)
: defaultWidth;
}
const debouncedWrite = useMemo(
() =>
debounce((width: number): void => {
setLocalStorageKey(storageKey, String(width));
}, PERSIST_DEBOUNCE_MS),
[storageKey],
);
const persistWidth = useCallback(
(width: number): void => {
debouncedWrite(clamp(width, minWidth, maxWidth));
},
[debouncedWrite, minWidth, maxWidth],
);
return { initialWidth: initialWidthRef.current, persistWidth };
}
export default usePanelWidth;

View File

@@ -1,66 +0,0 @@
import { render, screen } from '@testing-library/react';
import { linkifyText } from '../linkifyText';
describe('linkifyText', () => {
it('returns plain text unchanged when there are no links', () => {
render(<div>{linkifyText('just a plain description')}</div>);
expect(screen.getByText('just a plain description')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('wraps an http(s) URL in an anchor that opens in a new tab', () => {
render(<div>{linkifyText('see https://signoz.io/docs for more')}</div>);
const link = screen.getByRole('link', { name: 'https://signoz.io/docs' });
expect(link).toHaveAttribute('href', 'https://signoz.io/docs');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('prefixes bare www. links with https://', () => {
render(<div>{linkifyText('visit www.signoz.io')}</div>);
const link = screen.getByRole('link', { name: 'www.signoz.io' });
expect(link).toHaveAttribute('href', 'https://www.signoz.io');
});
it('keeps trailing punctuation outside the link', () => {
render(<div>{linkifyText('read https://signoz.io.')}</div>);
const link = screen.getByRole('link', { name: 'https://signoz.io' });
expect(link).toHaveAttribute('href', 'https://signoz.io');
});
it('linkifies multiple URLs in the same string', () => {
render(
<div>{linkifyText('a https://one.com and b https://two.com end')}</div>,
);
expect(screen.getAllByRole('link')).toHaveLength(2);
expect(
screen.getByRole('link', { name: 'https://one.com' }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://two.com' }),
).toBeInTheDocument();
});
it('preserves newlines around a link', () => {
const { container } = render(
<div>{linkifyText('line one\nsee https://signoz.io\nline three')}</div>,
);
expect(container.textContent).toBe(
'line one\nsee https://signoz.io\nline three',
);
expect(
screen.getByRole('link', { name: 'https://signoz.io' }),
).toHaveAttribute('href', 'https://signoz.io');
});
it('returns an empty string unchanged', () => {
expect(linkifyText('')).toBe('');
});
});

View File

@@ -1,71 +0,0 @@
import { Fragment, MouseEvent, ReactNode } from 'react';
/** Matches http(s) URLs and bare www. links up to the next whitespace. */
const URL_REGEX = /((?:https?:\/\/|www\.)[^\s]+)/gi;
/** Trailing punctuation that is almost never part of the intended URL. */
const TRAILING_PUNCTUATION = /[.,;:!?)\]}'"]+$/;
const stopPropagation = (
event: MouseEvent<HTMLAnchorElement, globalThis.MouseEvent>,
): void => {
// Prevent parent click listeners (e.g. title edit) from firing.
event.stopPropagation();
};
/**
* Splits `text` into plain-text and anchor segments, wrapping any detected
* URL in an anchor that opens in a new tab. Trailing punctuation is kept
* outside the link so sentences like "see https://signoz.io." stay clean.
*/
export function linkifyText(text: string): ReactNode {
if (!text) {
return text;
}
const segments: ReactNode[] = [];
let lastIndex = 0;
let key = 0;
const matches = text.matchAll(URL_REGEX);
for (const match of matches) {
const matchStart = match.index ?? 0;
const rawUrl = match[0];
const trailing = rawUrl.match(TRAILING_PUNCTUATION)?.[0] ?? '';
const url = trailing ? rawUrl.slice(0, -trailing.length) : rawUrl;
const href = url.startsWith('www.') ? `https://${url}` : url;
if (matchStart > lastIndex) {
segments.push(
<Fragment key={key}>{text.slice(lastIndex, matchStart)}</Fragment>,
);
key += 1;
}
segments.push(
<a
key={key}
href={href}
rel="noopener noreferrer"
target="_blank"
onClick={stopPropagation}
>
{url}
</a>,
);
key += 1;
if (trailing) {
segments.push(<Fragment key={key}>{trailing}</Fragment>);
key += 1;
}
lastIndex = matchStart + rawUrl.length;
}
if (lastIndex < text.length) {
segments.push(<Fragment key={key}>{text.slice(lastIndex)}</Fragment>);
}
return segments;
}

View File

@@ -1,97 +0,0 @@
/**
* Stylelint rule: local/no-font-properties
*
* Bans direct font/text CSS properties with hardcoded values.
* Use Typography component from @signozhq/ui or design tokens.
*
* BAD:
* font-family: Arial;
* font-weight: bold;
* font-size: 14px;
* line-height: 1.5;
* letter-spacing: 1px;
*
* GOOD (prefer Typography component):
* <Typography size="base" weight="medium">Text</Typography>
*
* GOOD (CSS tokens when Typography not applicable):
* font-size: var(--font-size-base);
* font-weight: var(--font-weight-medium);
* line-height: var(--line-height-normal);
* letter-spacing: var(--letter-spacing-normal);
*
* Available tokens from @signozhq/design-tokens:
* --font-size-{xs,sm,base,lg,xl,2xl...9xl}
* --font-weight-{thin,extralight,light,normal,medium,semibold,bold,extrabold,black}
* --line-height-{none,tight,snug,normal,relaxed,loose}
* --letter-spacing-{tighter,tight,normal,wide,wider}
* --periscope-font-size-{small,base,medium,large}
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-font-properties';
const messages = stylelint.utils.ruleMessages(ruleName, {
banned: (property) =>
`Direct '${property}' with hardcoded value is banned. Use Typography component or design tokens (--font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*).`,
});
const BANNED_PROPERTIES = new Set([
'font-family',
'font-weight',
'font-size',
'font-style',
'font',
'text-transform',
'text-decoration',
'letter-spacing',
'line-height',
'word-spacing',
'text-indent',
]);
// Allow if value uses CSS variable tokens
function usesDesignToken(value) {
return /var\s*\(\s*--/.test(value);
}
// Allow inherit/initial/unset
function isAllowedValue(value) {
const lower = value.toLowerCase().trim();
return ['inherit', 'initial', 'unset', 'normal', 'none'].includes(lower);
}
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkDecls((decl) => {
const prop = decl.prop.toLowerCase();
if (!BANNED_PROPERTIES.has(prop)) {
return;
}
// Allow if using design tokens or allowed values
if (usesDesignToken(decl.value) || isAllowedValue(decl.value)) {
return;
}
stylelint.utils.report({
message: messages.banned(decl.prop),
node: decl,
result,
ruleName,
});
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -1,87 +0,0 @@
/**
* Stylelint rule: local/no-primitive-color-tokens
*
* Bans primitive color tokens from @signozhq/design-tokens.
* Use semantic tokens instead.
*
* PRIMITIVE (banned):
* --bg-robin-500, --bg-cherry-200, --bg-slate-400
* --text-robin-500, --text-vanilla-100
* --bg-gradient-coral, --text-gradient-dawn
* --bg-base-black, --text-base-white
*
* SEMANTIC (allowed):
* --l1-background, --l2-foreground, --l3-border
* --primary-*, --secondary-*, --accent-*
* --danger-*, --warning-*, --success-*
* --callout-*, --chart-*, --sidebar-*
*
* See docs/css-modules-guide.md for semantic token reference.
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-primitive-color-tokens';
const messages = stylelint.utils.ruleMessages(ruleName, {
primitiveToken: (token) =>
`Primitive color token '${token}' is banned. Use semantic tokens (--l1-*, --l2-*, --l3-*, --accent-*, --primary-*, etc). See docs/css-modules-guide.md.`,
});
// Primitive color names from @signozhq/design-tokens
const PRIMITIVE_COLORS = [
'robin',
'sienna',
'cherry',
'aqua',
'sakura',
'amber',
'forest',
'ink',
'vanilla',
'slate',
'neutraldark',
'neutrallight',
'base',
'gradient',
];
// Matches: --bg-<color>-<number|name> or --text-<color>-<number|name>
// Examples: --bg-robin-500, --text-cherry-400, --bg-base-black, --bg-gradient-coral
const PRIMITIVE_TOKEN_PATTERN = new RegExp(
`--(bg|text)-(${PRIMITIVE_COLORS.join('|')})-[a-z0-9-]+`,
'gi',
);
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkDecls((decl) => {
const value = decl.value;
// Reset lastIndex for global regex
PRIMITIVE_TOKEN_PATTERN.lastIndex = 0;
const matches = value.match(PRIMITIVE_TOKEN_PATTERN);
if (matches) {
for (const token of matches) {
stylelint.utils.report({
message: messages.primitiveToken(token),
node: decl,
result,
ruleName,
});
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -1,59 +0,0 @@
/**
* Stylelint rule: local/no-styles-scss
*
* Bans files named styles.scss (non-module).
* Use .module.scss for CSS modules instead.
*
* BAD:
* styles.scss
* Component.styles.scss
*
* GOOD:
* Component.module.scss
* styles.module.scss
*/
import stylelint from 'stylelint';
import path from 'path';
const ruleName = 'local/no-styles-scss';
const messages = stylelint.utils.ruleMessages(ruleName, {
banned: (filename) =>
`Non-module SCSS file '${filename}' is banned. Use .module.scss for CSS modules instead.`,
});
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
const filename = result.opts?.from;
if (!filename) {
return;
}
const basename = path.basename(filename);
// Check if it's a non-module styles.scss file
if (
basename.endsWith('.scss') &&
!basename.endsWith('.module.scss') &&
(basename === 'styles.scss' || basename.includes('.styles.scss'))
) {
stylelint.utils.report({
message: messages.banned(basename),
node: root,
result,
ruleName,
});
}
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -117,16 +117,8 @@ var podsSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{
"k8s.pod.phase",
"k8s.container.restarts", // pod restart count (default-on)
},
OptionalMetrics: []string{
// kubectl-style pod display status (default-off in the receiver).
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -157,10 +149,6 @@ var nodesSpec = checkSpec{
// By default, only k8s.node.condition_ready is enabled. (Check https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/4f9a578b210a6dcb9f9bf47942f27208b5765298/receiver/k8sclusterreceiver/metadata.yaml#L802)
"k8s.pod.phase", // pod counts per node by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -194,10 +182,6 @@ var deploymentsSpec = checkSpec{
"k8s.deployment.desired",
"k8s.deployment.available",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -236,10 +220,6 @@ var daemonsetsSpec = checkSpec{
"k8s.daemonset.desired_scheduled_nodes",
"k8s.daemonset.current_scheduled_nodes",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -278,10 +258,6 @@ var statefulsetsSpec = checkSpec{
"k8s.statefulset.desired_pods",
"k8s.statefulset.current_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -322,10 +298,6 @@ var jobsSpec = checkSpec{
"k8s.job.failed_pods",
"k8s.job.successful_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -352,12 +324,8 @@ var namespacesSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -392,10 +360,6 @@ var clustersSpec = checkSpec{
"k8s.node.condition_ready", // node counts by readiness
"k8s.pod.phase", // pod counts per cluster by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{

View File

@@ -20,7 +20,6 @@ func buildClusterRecords(
metadataMap map[string]map[string]string,
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -70,10 +69,6 @@ func buildClusterRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

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