mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 15:40:40 +01:00
Compare commits
29 Commits
ci/better-
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2af5b3697 | ||
|
|
d5e72145f9 | ||
|
|
3ac9e1d6e1 | ||
|
|
73aa7d32b1 | ||
|
|
14d9bbfce9 | ||
|
|
9c48d17fed | ||
|
|
33c6cbecda | ||
|
|
8cbdf311af | ||
|
|
81995f54bb | ||
|
|
9429c9e632 | ||
|
|
7821961a6f | ||
|
|
08de39cdbd | ||
|
|
98178795ec | ||
|
|
0797b26cb2 | ||
|
|
f8846026fb | ||
|
|
5ec6560613 | ||
|
|
46e092b2c9 | ||
|
|
fe660e27a2 | ||
|
|
465620fbbb | ||
|
|
db2e29dde9 | ||
|
|
cec72e25fd | ||
|
|
82c833044d | ||
|
|
f98b8bb1fd | ||
|
|
2114fbddfe | ||
|
|
b2214cf5a3 | ||
|
|
db4219e0f1 | ||
|
|
165c945511 | ||
|
|
ff19aedaa8 | ||
|
|
ef8319585b |
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -48,7 +48,11 @@ jobs:
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- querier
|
||||
- querierlogs
|
||||
- queriertraces
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
41
.github/workflows/jsci.yaml
vendored
41
.github/workflows/jsci.yaml
vendored
@@ -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 }}
|
||||
|
||||
@@ -543,6 +543,31 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesGettableToken:
|
||||
properties:
|
||||
accessToken:
|
||||
@@ -694,43 +719,6 @@ components:
|
||||
- detach
|
||||
type: string
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesRoleWithTransactionGroups:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -758,6 +746,18 @@ components:
|
||||
- orgId
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesRoleMapping:
|
||||
properties:
|
||||
defaultRole:
|
||||
type: string
|
||||
groupMappings:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
@@ -11828,7 +11828,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesGettableRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -12012,7 +12012,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -119,10 +119,6 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return provider.openfgaServer.Write(ctx, additions, deletions)
|
||||
}
|
||||
@@ -131,10 +127,6 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
|
||||
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
|
||||
}
|
||||
@@ -183,7 +175,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -208,70 +200,31 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return provider.store.Create(ctx, role)
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return existingRole, nil
|
||||
}
|
||||
|
||||
err = provider.store.Create(ctx, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return role, nil
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,7 +240,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
return provider.store.Update(ctx, orgID, updatedRole)
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -296,7 +249,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
|
||||
role, err := provider.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -312,7 +265,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -324,6 +277,24 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return provider.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
|
||||
tuples := []*openfgav1.TupleKey{}
|
||||
|
||||
@@ -375,21 +346,3 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
|
||||
|
||||
return tuples
|
||||
}
|
||||
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
|
||||
User: subject,
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
|
||||
}
|
||||
|
||||
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
|
||||
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
|
||||
}
|
||||
|
||||
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return server.pkgAuthzService.Write(ctx, additions, deletions)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
@@ -81,6 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"html-to-image": "1.11.13",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -164,6 +164,9 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5451,6 +5454,9 @@ packages:
|
||||
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
|
||||
engines: {node: '>=20.10'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -14495,6 +14501,8 @@ snapshots:
|
||||
|
||||
html-tags@5.1.0: {}
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
|
||||
@@ -62,6 +62,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -87,6 +87,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -515,17 +514,17 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2082,6 +2082,39 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2325,39 +2358,6 @@ export interface AuthtypesPostableUserRoleDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleWithTransactionGroupsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -10399,7 +10399,7 @@ export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesRoleDTO[];
|
||||
data: AuthtypesGettableRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10421,7 +10421,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
data: AuthtypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,7 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<TooltipSimple title={String(value)} side="top" align="start">
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import APIError from 'types/api/error';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -16,7 +16,7 @@ export interface RoleOption {
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: AuthtypesRoleDTO[];
|
||||
roles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
@@ -33,7 +33,7 @@ export function useRoles(): {
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
roles: AuthtypesGettableRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
@@ -79,7 +79,7 @@ interface BaseProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: AuthtypesRoleDTO[];
|
||||
roles?: AuthtypesGettableRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -24,7 +24,7 @@ interface OverviewTabProps {
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
canUpdate?: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
availableRoles: AuthtypesGettableRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 50;
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
z-index: 1001 !important;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export enum LOCALSTORAGE {
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
|
||||
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
|
||||
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
|
||||
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
|
||||
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -90,7 +90,8 @@ const ROUTES = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
|
||||
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -163,12 +163,23 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,49 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<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="api-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -27,7 +55,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</section>
|
||||
</ResizableBox>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -66,6 +66,7 @@ function WidgetGraphComponent({
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
hidePagination,
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
@@ -430,6 +431,7 @@ function WidgetGraphComponent({
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
hidePagination={hidePagination}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface WidgetGraphComponentProps {
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
/** Hide list-panel pagination controls (e.g. public dashboards, where paging isn't supported). */
|
||||
hidePagination?: boolean;
|
||||
}
|
||||
|
||||
export interface GridCardGraphProps {
|
||||
|
||||
@@ -6,6 +6,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -40,8 +43,21 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -139,7 +155,18 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<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={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -156,7 +183,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,26 +44,20 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -142,7 +136,9 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -44,26 +44,15 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -128,7 +117,9 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -219,8 +210,16 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -56,9 +59,23 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -212,7 +229,18 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<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={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -265,7 +293,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
// Shell for the LLM Observability page: renders the top-level tab bar
|
||||
// (Overview / Configuration) using the SigNoz design-system Tabs, with
|
||||
// route-driven active state from useLLMObservabilityTabs.
|
||||
function LLMObservability(): JSX.Element {
|
||||
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
|
||||
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
// Overview tab content for LLM Observability. Currently the feature's landing
|
||||
// surface; usage/cost/performance widgets land in later PRs.
|
||||
function Overview(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
@@ -2,29 +2,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
|
||||
onDelete={deletion.requestDelete}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TOAST_MODEL_COST_DELETED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_PRICING_RULE_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import ModelCostTabPanel from '../ModelCostTabPanel';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules, total = items.length): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetUrl(): void {
|
||||
window.history.pushState(null, '', '/');
|
||||
}
|
||||
|
||||
async function openRowMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
|
||||
await user.click(within(row as HTMLElement).getByRole('button'));
|
||||
}
|
||||
|
||||
describe('ModelCostTabPanel (integration)', () => {
|
||||
beforeEach(() => {
|
||||
resetUrl();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders pricing rules returned by the list API', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
|
||||
expect(openaiCell).toHaveTextContent('gpt-4o');
|
||||
expect(
|
||||
screen.getByTestId('model-cell-name-rule-anthropic'),
|
||||
).toHaveTextContent('claude-3-5-sonnet');
|
||||
// Canonical id under the model name + provider column.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
||||
// Source badges reflect the override flag.
|
||||
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
|
||||
'User override',
|
||||
);
|
||||
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
|
||||
'Auto',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no rules', async () => {
|
||||
setupList([], 0);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const empty = await screen.findByTestId('model-costs-empty');
|
||||
expect(empty).toHaveTextContent('No model costs yet.');
|
||||
});
|
||||
|
||||
it('shows an error message when the list request fails', async () => {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent(
|
||||
'Failed to load pricing rules. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the debounced search term as the q param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search by model or provider'),
|
||||
'claude',
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
|
||||
});
|
||||
|
||||
it('clears the search via the clear button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Search by model or provider',
|
||||
) as HTMLInputElement;
|
||||
await user.type(input, 'gpt');
|
||||
expect(input.value).toBe('gpt');
|
||||
|
||||
await user.click(screen.getByTestId('model-cost-search-clear'));
|
||||
await waitFor(() => expect(input.value).toBe(''));
|
||||
});
|
||||
|
||||
it('sends isOverride=true when the source filter is set to User override', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('source-filter'));
|
||||
// Scope to the listbox option — "User override" also appears as a row badge.
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'User override' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
|
||||
});
|
||||
|
||||
it('opens the add drawer for a manager (ADMIN)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const modelInput = await screen.findByTestId('drawer-model-id-input');
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the add button and row actions for a viewer', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const row = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
|
||||
// View-only rows render no action menu (no buttons in the row).
|
||||
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled from the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
const drawerTitle = await screen.findByText('Edit model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('deletes a rule through the confirm dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let deletedId: string | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Delete'));
|
||||
|
||||
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(deletedId).toBe('rule-openai'));
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_DELETED),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cache buckets for rules that have cache pricing', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const anthropicRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-anthropic')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
|
||||
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats per-million prices in the row', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
// mockRules gpt-4o has input cost 3 → rendered as $3.00.
|
||||
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a normalized create payload when adding a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
// Leading/trailing whitespace should be trimmed off the model id.
|
||||
await user.type(
|
||||
await screen.findByTestId('drawer-model-id-input'),
|
||||
' gpt-4o-mini ',
|
||||
);
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
// The create call submits a bulk `rules` array of normalized payloads.
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
expect(payload).toMatchObject({
|
||||
modelName: 'gpt-4o-mini',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 3, output: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an updated payload when editing a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
// Model id + provider are locked in edit mode; change the prefilled input cost.
|
||||
const inputCost = await screen.findByTestId('drawer-input-cost');
|
||||
await user.clear(inputCost);
|
||||
await user.type(inputCost, '5');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
// Edit carries the rule id; disabled model/provider are still submitted and
|
||||
// the edited price flows through, while output keeps its prefilled value.
|
||||
expect(payload).toMatchObject({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 5, output: 9 },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_UPDATED),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { makePricingRule } from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { EMPTY_DRAFT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import { draftFromRule } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
const editDraft = draftFromRule(
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
}),
|
||||
);
|
||||
|
||||
describe('ModelCostDrawer (integration)', () => {
|
||||
it('renders the add title and a save button for a manager', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add model cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save until the form is dirty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the model id required error and does not call onSave when the name is empty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Make the form dirty without touching the model id: add a pattern, which
|
||||
// mutates the `patterns` form field while leaving the name empty.
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
const error = await screen.findByText('Billing model ID is required.');
|
||||
expect(error).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave once on the happy path with valid model id and pricing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders a read-only view with a Close button and no save for a viewer', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('View model cost')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
it('renders the save error text', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError="boom"
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds and removes a model pattern from the editor', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
// The added pattern renders as a removable chip.
|
||||
const removeChip = screen.getByRole('button', {
|
||||
name: 'Remove pattern gpt-5',
|
||||
});
|
||||
expect(removeChip).toBeInTheDocument();
|
||||
|
||||
await user.click(removeChip);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove pattern gpt-5' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a cache pricing bucket via the picker and removes it', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
|
||||
|
||||
// Adding the bucket reveals its cost input and the shared cache-mode select.
|
||||
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('drawer-remove-cache-read'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-cache-read-cost'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks save with a pricing error when an override rule has no input cost', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// EMPTY_DRAFT defaults to an override with empty pricing. Fill only the
|
||||
// model id + output cost so the form is dirty but the input cost is missing.
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Input cost must be greater than 0.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires confirmation to reset an override rule back to auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pricing is editable while the rule is an override.
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Picking "auto" surfaces a confirm step instead of applying immediately.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Keep backs out of the reset.
|
||||
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-reset-confirm-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Confirming the reset flips the rule to auto and locks pricing.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
@@ -76,7 +80,9 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
|
||||
toast.success(
|
||||
mode === 'edit' ? TOAST_MODEL_COST_UPDATED : TOAST_MODEL_COST_ADDED,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.modelCostsTable {
|
||||
margin-top: var(--spacing-8);
|
||||
--tanstack-table-row-height: 48px;
|
||||
height: calc(100vh - 250px);
|
||||
height: calc(100vh - 170px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { TOAST_MODEL_COST_DELETED } from '../../constants';
|
||||
import type { PricingRule } from '../../types';
|
||||
|
||||
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
|
||||
@@ -49,7 +50,7 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
setPendingDelete(null);
|
||||
toast.success('Model cost deleted');
|
||||
toast.success(TOAST_MODEL_COST_DELETED);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
toast.error(message);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
export function makePricingRule(
|
||||
overrides: Partial<PricingRule> = {},
|
||||
): PricingRule {
|
||||
const { pricing, ...rest } = overrides;
|
||||
return {
|
||||
id: 'rule-1',
|
||||
enabled: true,
|
||||
isOverride: true,
|
||||
modelName: 'gpt-4o',
|
||||
modelPattern: ['gpt-4o'],
|
||||
orgId: 'org-1',
|
||||
provider: 'OpenAI',
|
||||
sourceId: 'source-1',
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
createdAt: '2023-10-01T00:00:00.000Z',
|
||||
updatedAt: '2023-10-10T00:00:00.000Z',
|
||||
syncedAt: '2023-10-10T00:00:00.000Z',
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
...pricing,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockRules: PricingRule[] = [
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
pricing: { input: 3, output: 9 },
|
||||
}),
|
||||
makePricingRule({
|
||||
id: 'rule-anthropic',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
provider: 'Anthropic',
|
||||
isOverride: false,
|
||||
pricing: {
|
||||
input: 2,
|
||||
output: 30,
|
||||
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
items: PricingRule[],
|
||||
total = items.length,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
): ListLLMPricingRules200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type { CacheBucketDef, DrawerDraft } from './types';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const TOAST_MODEL_COST_ADDED = 'Model cost added';
|
||||
export const TOAST_MODEL_COST_UPDATED = 'Model cost updated';
|
||||
export const TOAST_MODEL_COST_DELETED = 'Model cost deleted';
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
export const SEARCH_KEY = 'search';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LLMObservability from '../LLMObservability';
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservability (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Model pricing' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-model-pricing-page'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
|
||||
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
|
||||
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
|
||||
// design-system Tabs.
|
||||
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
|
||||
? CONFIGURATION_KEY
|
||||
: OVERVIEW_KEY;
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
safeNavigate(key);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: OVERVIEW_KEY,
|
||||
label: 'Overview',
|
||||
children: <Overview />,
|
||||
},
|
||||
{
|
||||
key: CONFIGURATION_KEY,
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
}
|
||||
@@ -34,6 +34,7 @@ function LogsPanelComponent({
|
||||
setRequestData,
|
||||
queryResponse,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: LogsPanelComponentProps): JSX.Element {
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
@@ -158,7 +159,7 @@ function LogsPanelComponent({
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
{!widget.query.builder.queryData[0].limit && (
|
||||
{!hidePagination && !widget.query.builder.queryData[0].limit && (
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
@@ -198,6 +199,7 @@ export type LogsPanelComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
hidePagination?: boolean;
|
||||
};
|
||||
|
||||
export default LogsPanelComponent;
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
@@ -78,7 +89,9 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -18,6 +19,8 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -30,6 +33,10 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
@@ -55,6 +62,16 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -127,10 +144,19 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
<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={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -142,7 +168,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -9,6 +9,7 @@ function ListPanelWrapper({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
|
||||
|
||||
@@ -23,6 +24,7 @@ function ListPanelWrapper({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +34,7 @@ function ListPanelWrapper({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function PanelWrapper({
|
||||
panelMode,
|
||||
enableDrillDown = false,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
selectedGraph || widget.panelTypes
|
||||
@@ -76,6 +77,7 @@ function PanelWrapper({
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type PanelWrapperProps = {
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
hidePagination?: boolean;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
|
||||
@@ -6,11 +6,12 @@ import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphC
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
|
||||
import { getPublicPanelRequestData } from './utils';
|
||||
|
||||
function Panel({
|
||||
widget,
|
||||
index,
|
||||
@@ -27,54 +28,27 @@ function Panel({
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
const requestData: GetQueryResultsProps = useMemo(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
// State (not memo) so LIST panels get a setRequestData — ListPanelWrapper
|
||||
// renders nothing without one.
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() =>
|
||||
getPublicPanelRequestData({ widget, startTime, endTime }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(updatedQuery, requestData.query)) {
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
query: updatedQuery,
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
const updatedQueryForList = {
|
||||
...updatedQuery,
|
||||
builder: {
|
||||
...updatedQuery.builder,
|
||||
queryData: updatedQuery.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: 10 } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
query: updatedQueryForList,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
}, [widget, updatedQuery, startTime, endTime]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updatedQuery]);
|
||||
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget?.panelTypes,
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -140,6 +114,8 @@ function Panel({
|
||||
headerMenuList={[]}
|
||||
isWarning={false}
|
||||
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
|
||||
setRequestData={setRequestData}
|
||||
hidePagination
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,9 +19,14 @@ jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const widgetGraphProps = jest.fn();
|
||||
|
||||
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="widget-graph" />,
|
||||
default: (props: { setRequestData?: unknown }): JSX.Element => {
|
||||
widgetGraphProps(props);
|
||||
return <div data-testid="widget-graph" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const buildWidget = (id: string): Widgets =>
|
||||
@@ -39,6 +44,22 @@ const buildWidget = (id: string): Widgets =>
|
||||
describe('Public dashboard Panel', () => {
|
||||
beforeEach(() => {
|
||||
useGetQueryRangeMock.mockClear();
|
||||
widgetGraphProps.mockClear();
|
||||
});
|
||||
|
||||
it('forwards a setRequestData setter so LIST panels render (bug 3646)', () => {
|
||||
render(
|
||||
<Panel
|
||||
widget={buildWidget('widget-a')}
|
||||
index={0}
|
||||
dashboardId="dash-1"
|
||||
startTime={100}
|
||||
endTime={200}
|
||||
/>,
|
||||
);
|
||||
|
||||
const props = widgetGraphProps.mock.calls[0][0];
|
||||
expect(typeof props.setRequestData).toBe('function');
|
||||
});
|
||||
|
||||
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
|
||||
|
||||
53
frontend/src/container/PublicDashboardContainer/utils.ts
Normal file
53
frontend/src/container/PublicDashboardContainer/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
|
||||
// Builds the useGetQueryRange payload for a public-dashboard panel, mirroring the
|
||||
// authenticated GridCard.
|
||||
export const getPublicPanelRequestData = ({
|
||||
widget,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): GetQueryResultsProps => {
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
query: updatedQuery,
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}
|
||||
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
|
||||
return {
|
||||
query: updatedQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -22,7 +22,7 @@ const PAGE_SIZE = 20;
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'section'; label: string; count?: number }
|
||||
| { type: 'role'; role: AuthtypesRoleDTO };
|
||||
| { type: 'role'; role: AuthtypesGettableRoleDTO };
|
||||
|
||||
interface RolesListingTableProps {
|
||||
searchQuery: string;
|
||||
@@ -190,7 +190,7 @@ function RolesListingTable({
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={cx(styles.tableRow, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import type {
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesRoleWithTransactionGroupsDTO,
|
||||
AuthtypesRoleDTO,
|
||||
AuthtypesTransactionGroupDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
|
||||
}
|
||||
|
||||
export function transformApiToRolePermissions(
|
||||
role: AuthtypesRoleWithTransactionGroupsDTO,
|
||||
role: AuthtypesRoleDTO,
|
||||
): RolePermissionsData {
|
||||
return {
|
||||
roleId: role.id,
|
||||
|
||||
@@ -203,8 +203,8 @@ export const routesToSkip = [
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -37,6 +37,7 @@ function TracesTableComponent({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: TracesTableComponentProps): JSX.Element {
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
@@ -139,34 +140,36 @@ function TracesTableComponent({
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
isLoading={queryResponse.isFetching}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
handleNavigatePrevious={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset - pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleNavigateNext={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleCountItemsPerPageChange={(value): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
limit: value,
|
||||
offset: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!hidePagination && (
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
isLoading={queryResponse.isFetching}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
handleNavigatePrevious={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset - pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleNavigateNext={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleCountItemsPerPageChange={(value): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
limit: value,
|
||||
offset: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -178,6 +181,7 @@ export type TracesTableComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
hidePagination?: boolean;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
};
|
||||
|
||||
|
||||
30
frontend/src/hooks/__tests__/useLogsData.test.tsx
Normal file
30
frontend/src/hooks/__tests__/useLogsData.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useLogsData } from '../useLogsData';
|
||||
|
||||
describe('useLogsData', () => {
|
||||
// Public dashboards redact the widget query (orderBy/filter/limit stripped),
|
||||
// so a LIST query can arrive with no orderBy — the hook must not crash on it.
|
||||
it('does not crash when the query has no orderBy', () => {
|
||||
const stagedQuery = {
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'logs', queryName: 'A', disabled: false }],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogsData({
|
||||
result: undefined,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
expect(result.current.logs).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -30,4 +30,6 @@ export const useGetFieldKeys = ({
|
||||
queryKey: ['fieldKeys', signal, name],
|
||||
queryFn: () => getFieldKeys(signal, name),
|
||||
enabled,
|
||||
// Keep prior keys during a search refetch so the dropdown doesn't blank out.
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getGetRolesByUserIDQueryKey,
|
||||
useGetRolesByUserID,
|
||||
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseMemberRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<MemberRoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
|
||||
{ query: { enabled: !!userId && enabled } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<MemberRoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseServiceAccountRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<RoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
|
||||
{ query: { enabled: options?.enabled ?? true } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<RoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -71,7 +71,7 @@ export const useLogsData = ({
|
||||
}, [logs.length, listQuery]);
|
||||
|
||||
const orderByTimestamp: OrderByPayload | null = useMemo(() => {
|
||||
const timestampOrderBy = listQuery?.orderBy.find(
|
||||
const timestampOrderBy = listQuery?.orderBy?.find(
|
||||
(item) => item.columnName === 'timestamp',
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAuthZDevStore } from '../useAuthZDevStore';
|
||||
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
|
||||
@@ -17,7 +16,6 @@ export function AuthZDevModal(): JSX.Element | null {
|
||||
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);
|
||||
@@ -25,8 +23,6 @@ export function AuthZDevModal(): JSX.Element | null {
|
||||
|
||||
useAuthZQueryInvalidation(overrides);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
@@ -37,29 +33,18 @@ export function AuthZDevModal(): JSX.Element | null {
|
||||
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],
|
||||
[closeModal],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -73,7 +58,6 @@ export function AuthZDevModal(): JSX.Element | null {
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<AuthZDevModalHeader
|
||||
searchInputRef={searchInputRef}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
resourceFilter={resourceFilter}
|
||||
@@ -91,9 +75,6 @@ export function AuthZDevModal(): JSX.Element | null {
|
||||
observedListLength={observedList.length}
|
||||
orderedPermissions={orderedPermissions}
|
||||
groups={groups}
|
||||
indexByPermission={indexByPermission}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
observed={observed}
|
||||
overrides={overrides}
|
||||
onSetOverride={setOverride}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { ObservedPermission, OverrideState } from '../types';
|
||||
@@ -17,9 +16,6 @@ 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;
|
||||
@@ -29,20 +25,10 @@ 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 ? (
|
||||
@@ -64,19 +50,14 @@ export function AuthZDevModalContent({
|
||||
{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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{group.items.map((permission) => (
|
||||
<PermissionRow
|
||||
key={permission}
|
||||
observed={observed[permission]}
|
||||
override={overrides[permission]}
|
||||
onSetOverride={onSetOverride}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -15,32 +15,6 @@ export function AuthZDevModalFooter({
|
||||
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">
|
||||
|
||||
@@ -2,14 +2,13 @@ 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 { 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;
|
||||
@@ -25,7 +24,6 @@ export interface AuthZDevModalHeaderProps {
|
||||
}
|
||||
|
||||
export function AuthZDevModalHeader({
|
||||
searchInputRef,
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
@@ -56,7 +54,6 @@ export function AuthZDevModalHeader({
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search permissions..."
|
||||
value={search}
|
||||
onChange={(e): void => setSearch(e.target.value)}
|
||||
|
||||
@@ -33,11 +33,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { parsePermission } from '../../hooks/useAuthZ/utils';
|
||||
@@ -14,9 +14,7 @@ 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> = {
|
||||
@@ -30,9 +28,7 @@ const ROW_OVERRIDE_CLASSES: Record<OverrideState, string | null> = {
|
||||
export const PermissionRow = memo(function PermissionRow({
|
||||
observed,
|
||||
override,
|
||||
isSelected,
|
||||
onSetOverride,
|
||||
onSelect,
|
||||
}: PermissionRowProps): JSX.Element {
|
||||
const currentState = override ?? OverrideState.Reset;
|
||||
|
||||
@@ -48,14 +44,6 @@ export const PermissionRow = memo(function PermissionRow({
|
||||
};
|
||||
}, [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) {
|
||||
@@ -68,9 +56,7 @@ export const PermissionRow = memo(function PermissionRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState], {
|
||||
[styles.isSelected]: isSelected,
|
||||
})}
|
||||
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState])}
|
||||
data-testid={`permission-row-${observed.permission}`}
|
||||
>
|
||||
<div className={styles.permissionInfo}>
|
||||
@@ -106,7 +92,7 @@ export const PermissionRow = memo(function PermissionRow({
|
||||
<OverrideControl
|
||||
permission={observed.permission}
|
||||
value={currentState}
|
||||
onSelect={handleSetOverride}
|
||||
onSelect={onSetOverride}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesGettableRoleDTO,
|
||||
AuthtypesRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
|
||||
|
||||
export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
export const managedRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-2248-756f-9833-984f1ab63819',
|
||||
createdAt: '2026-02-03T18:00:55.624356Z',
|
||||
@@ -35,7 +38,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const customRoles: AuthtypesRoleDTO[] = [
|
||||
export const customRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-3333-0001-aaaa-111111111111',
|
||||
createdAt: '2026-02-10T10:30:00.000Z',
|
||||
@@ -56,12 +59,24 @@ export const customRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
|
||||
export const allRoles: AuthtypesGettableRoleDTO[] = [
|
||||
...managedRoles,
|
||||
...customRoles,
|
||||
];
|
||||
|
||||
export const listRolesSuccessResponse = {
|
||||
status: 'success',
|
||||
data: allRoles,
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRoles[0] };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
|
||||
const customRole: AuthtypesRoleDTO = {
|
||||
...customRoles[0],
|
||||
transactionGroups: [],
|
||||
};
|
||||
const managedRole: AuthtypesRoleDTO = {
|
||||
...managedRoles[0],
|
||||
transactionGroups: [],
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRole };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRole };
|
||||
|
||||
@@ -20,12 +20,23 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.all-errors-quick-filter-section {
|
||||
width: 260px;
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
width: calc(100% - 260px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,18 @@ import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -55,17 +61,38 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<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="all-errors-quick-filter-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</section>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
|
||||
@@ -5,3 +5,9 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
|
||||
.toolbarButton {
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import {
|
||||
@@ -29,12 +35,15 @@ import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
@@ -58,7 +67,10 @@ function DashboardActions({
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -72,6 +84,14 @@ function DashboardActions({
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
useEffect(() => {
|
||||
if (settingsRequest) {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}
|
||||
}, [settingsRequest]);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
@@ -111,23 +131,38 @@ function DashboardActions({
|
||||
});
|
||||
}, [deleteDashboardMutation]);
|
||||
|
||||
// Shown only to edit-permitted users, so the only disabled reason is the lock.
|
||||
const editLabel = useCallback(
|
||||
(text: string): ReactNode =>
|
||||
isLocked ? (
|
||||
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
),
|
||||
[isLocked],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
if (canEditDashboard) {
|
||||
dashboardGroup.push({
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
label: editLabel('Rename'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: isLocked,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
if (isAuthor || user.role === USER_ROLES.ADMIN) {
|
||||
dashboardGroup.push({
|
||||
key: 'lock',
|
||||
@@ -144,16 +179,6 @@ function DashboardActions({
|
||||
onClick: handle.enter,
|
||||
});
|
||||
|
||||
const layoutGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
layoutGroup.push({
|
||||
key: 'new-section',
|
||||
label: 'New section',
|
||||
icon: <SquareStack size={14} />,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
});
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
type: 'group',
|
||||
@@ -162,27 +187,39 @@ function DashboardActions({
|
||||
children: dashboardGroup,
|
||||
},
|
||||
];
|
||||
if (layoutGroup.length > 0) {
|
||||
// Omit the whole Layout group (header included) in view mode.
|
||||
if (canEditDashboard) {
|
||||
items.push({
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: layoutGroup,
|
||||
children: [
|
||||
{
|
||||
key: 'new-section',
|
||||
label: editLabel('New section'),
|
||||
icon: <SquareStack size={14} />,
|
||||
disabled: isLocked,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
});
|
||||
items.push(
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: editLabel('Delete dashboard'),
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
disabled: isLocked,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
},
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete dashboard',
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
},
|
||||
);
|
||||
return items;
|
||||
}, [
|
||||
canEdit,
|
||||
editLabel,
|
||||
canEditDashboard,
|
||||
isLocked,
|
||||
isCloning,
|
||||
isAuthor,
|
||||
user.role,
|
||||
@@ -201,28 +238,40 @@ function DashboardActions({
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
{canEditDashboard && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={isLocked}
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
@@ -231,6 +280,7 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
@@ -238,17 +288,23 @@ function DashboardActions({
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
size="md"
|
||||
{canEditDashboard && (
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={isLocked}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
@@ -99,7 +100,7 @@ function DashboardInfo({
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -28,6 +30,11 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
// Inspect-only when not editable: Apply/Format/Reset disabled.
|
||||
const readOnly = !isEditable;
|
||||
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
@@ -39,7 +46,7 @@ function JsonEditorDrawer({
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
} = useJsonEditor({ dashboard, isOpen, readOnly, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -63,13 +70,15 @@ function JsonEditorDrawer({
|
||||
event.stopPropagation();
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
if (!readOnly) {
|
||||
void apply();
|
||||
}
|
||||
}
|
||||
},
|
||||
[apply],
|
||||
[apply, readOnly],
|
||||
);
|
||||
|
||||
const applyDisabled = !isDirty || !validity.valid || isSaving;
|
||||
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
@@ -150,16 +159,18 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
<DisabledControlTooltip reason={readOnlyReason} disabled={readOnly}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={readOnly ? undefined : (): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -168,6 +179,7 @@ function JsonEditorDrawer({
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<JsonEditorToolbar
|
||||
isDirty={isDirty}
|
||||
readOnly={readOnly}
|
||||
onFormat={format}
|
||||
onCopy={onCopy}
|
||||
onDownload={onDownload}
|
||||
@@ -180,6 +192,7 @@ function JsonEditorDrawer({
|
||||
value={draft}
|
||||
onChange={(value): void => setDraft(value ?? '')}
|
||||
options={{
|
||||
readOnly,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
|
||||
@@ -5,6 +5,8 @@ import styles from './JsonEditorToolbar.module.scss';
|
||||
|
||||
interface JsonEditorToolbarProps {
|
||||
isDirty: boolean;
|
||||
/** Locked/no-permission — Format and Reset (draft mutators) are disabled. */
|
||||
readOnly?: boolean;
|
||||
onFormat: () => void;
|
||||
onCopy: () => void;
|
||||
onDownload: () => void;
|
||||
@@ -13,6 +15,7 @@ interface JsonEditorToolbarProps {
|
||||
|
||||
function JsonEditorToolbar({
|
||||
isDirty,
|
||||
readOnly = false,
|
||||
onFormat,
|
||||
onCopy,
|
||||
onDownload,
|
||||
@@ -26,6 +29,7 @@ function JsonEditorToolbar({
|
||||
size="sm"
|
||||
prefix={<AlignLeft size={14} />}
|
||||
testId="json-editor-format"
|
||||
disabled={readOnly}
|
||||
onClick={onFormat}
|
||||
>
|
||||
Format
|
||||
@@ -57,7 +61,7 @@ function JsonEditorToolbar({
|
||||
size="sm"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
testId="json-editor-reset"
|
||||
disabled={!isDirty}
|
||||
disabled={readOnly || !isDirty}
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset
|
||||
|
||||
@@ -7,6 +7,13 @@ import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
|
||||
// Editable by default so the drawer renders in its editable (non-read-only) mode.
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
|
||||
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
|
||||
}));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface JsonValidity {
|
||||
interface Params {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
/** Locked/no-permission — `apply` is a no-op so edits can never be saved. */
|
||||
readOnly?: boolean;
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ function errorLineFromMessage(
|
||||
export function useJsonEditor({
|
||||
dashboard,
|
||||
isOpen,
|
||||
readOnly = false,
|
||||
onApplied,
|
||||
}: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
@@ -147,7 +150,7 @@ export function useJsonEditor({
|
||||
}, [appliedText]);
|
||||
|
||||
const apply = useCallback(async (): Promise<void> => {
|
||||
if (!validity.valid || !isDirty) {
|
||||
if (readOnly || !validity.valid || !isDirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -173,6 +176,7 @@ export function useJsonEditor({
|
||||
validity.valid,
|
||||
isDirty,
|
||||
draft,
|
||||
readOnly,
|
||||
refetch,
|
||||
onApplied,
|
||||
showErrorModal,
|
||||
|
||||
@@ -8,6 +8,8 @@ type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Unmount the content on close so it re-initializes on the next open. */
|
||||
destroyOnClose?: boolean;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
@@ -15,6 +17,7 @@ function SettingsDrawer({
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
destroyOnClose = false,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
@@ -23,6 +26,7 @@ function SettingsDrawer({
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
destroyOnClose={destroyOnClose}
|
||||
rootClassName={styles.settingsContainerRoot}
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -32,13 +35,13 @@ import styles from './DashboardPageToolbar.module.scss';
|
||||
interface DashboardPageToolbarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
const { dashboard, handle } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
@@ -101,12 +104,21 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
refetch();
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
}, [id, isDashboardLocked, queryClient, showErrorModal]);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
async (next: string): Promise<void> => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
|
||||
@@ -71,6 +72,7 @@ function DashboardInfoForm({
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -40,7 +40,7 @@ function DynamicVariableFields({
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const {
|
||||
@@ -82,38 +82,14 @@ function DynamicVariableFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
{/* Combined row retained from V1: the field on the left, `from` + the
|
||||
telemetry source on the right. */}
|
||||
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
className={styles.dynamicFieldSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
placeholder="Select a field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
onSearch={setSearch}
|
||||
@@ -126,6 +102,25 @@ function DynamicVariableFields({
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
<Typography.Text className={styles.fromText}>from</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
<Select
|
||||
className={styles.dynamicSourceSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
padding: 4px 16px !important;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
@@ -157,6 +157,7 @@
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
@@ -264,6 +265,7 @@
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -328,16 +330,54 @@
|
||||
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect,
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
// The clear (X) is an SVG, so antd's font-glyph offset mis-centers it — center
|
||||
// it on the row and make it clearly visible.
|
||||
:global(.ant-select-clear) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
transform: translateY(-50%);
|
||||
color: var(--l2-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dynamic variable: field | "from" | info | source, retained from V1. */
|
||||
.dynamicCombinedRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 16px 180px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fromText {
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -6,6 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput } from 'antd';
|
||||
import { CustomMultiSelect } from 'components/NewSelect';
|
||||
|
||||
import type { VariableType } from '../variableFormModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
@@ -30,9 +32,19 @@ function VariableForm({
|
||||
siblings,
|
||||
isNew,
|
||||
isSaving,
|
||||
panelOptions,
|
||||
appliedPanelIds,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
// The "apply to panels" selection is transient form state, seeded from the
|
||||
// panels that already reference this variable. Held in a ref so the save
|
||||
// callback (owned by useVariableForm) reads the latest value.
|
||||
const [selectedPanelIds, setSelectedPanelIds] =
|
||||
useState<string[]>(appliedPanelIds);
|
||||
const selectedPanelIdsRef = useRef(selectedPanelIds);
|
||||
selectedPanelIdsRef.current = selectedPanelIds;
|
||||
|
||||
const {
|
||||
model,
|
||||
set,
|
||||
@@ -54,7 +66,12 @@ function VariableForm({
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
} = useVariableForm({ initial, siblings, isNew, onSave });
|
||||
} = useVariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
onSave: (next): void => onSave(next, selectedPanelIdsRef.current),
|
||||
});
|
||||
|
||||
// Shared list rows (preview/sort/multi/default) for the list-type variables;
|
||||
// rendered as a sibling inside each list-type panel. Only the active panel
|
||||
@@ -101,6 +118,20 @@ function VariableForm({
|
||||
attributeError={attributeError}
|
||||
/>
|
||||
{listFields}
|
||||
<div className={styles.row}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Apply to panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CustomMultiSelect
|
||||
placeholder="Select panels"
|
||||
options={panelOptions}
|
||||
value={selectedPanelIds}
|
||||
onChange={(value): void => setSelectedPanelIds(value as string[])}
|
||||
data-testid="variable-apply-panels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -46,8 +46,12 @@ export interface UseVariableForm {
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
// `defaultValue` is a string | string[] on the wire; the editor uses a single
|
||||
// string, so take the first when it's an array.
|
||||
const readDefaultValue = (model: VariableFormModel): string => {
|
||||
const dv = model.defaultValue;
|
||||
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
|
||||
};
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
|
||||
@@ -3,18 +3,13 @@ import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariableRowProps {
|
||||
variable: VariableFormModel;
|
||||
index: number;
|
||||
@@ -25,9 +20,11 @@ interface VariableRowProps {
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
/** Apply this variable's filter to all panels. Dynamic variables only. */
|
||||
onApplyToAll: (index: number) => void;
|
||||
}
|
||||
|
||||
/** A single draggable variable row (drag handle + meta + inline actions). */
|
||||
/** A single draggable variable row in the two-column (name / description) table. */
|
||||
function VariableRow({
|
||||
variable,
|
||||
index,
|
||||
@@ -37,6 +34,7 @@ function VariableRow({
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onApplyToAll,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -61,7 +59,7 @@ function VariableRow({
|
||||
className={styles.row}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.varCell}>
|
||||
{canEdit ? (
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
@@ -74,65 +72,94 @@ function VariableRow({
|
||||
</span>
|
||||
) : null}
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
{variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.descCell}>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<span className={styles.varDescEmpty}>—</span>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<div
|
||||
className={cx(styles.rowActions, {
|
||||
[styles.rowActionsVisible]: isConfirmingDelete,
|
||||
})}
|
||||
>
|
||||
{isConfirmingDelete ? (
|
||||
<>
|
||||
<Typography.Text className={styles.confirmText}>
|
||||
Delete?
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{variable.type === 'DYNAMIC' ? (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title="Add this variable as a filter to every panel"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.applyAllButton}
|
||||
onClick={(): void => onApplyToAll(index)}
|
||||
testId={`variable-apply-all-${variable.name}`}
|
||||
>
|
||||
Apply to all
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && !isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
@@ -28,69 +28,125 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// Two-column (Variable / Description) table matching the V1 variables listing.
|
||||
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: grid;
|
||||
grid-template-columns: $grid-columns;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:first-child {
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: $grid-columns;
|
||||
padding: 0 16px;
|
||||
background: var(--l1-background);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.rowMain {
|
||||
.varCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px 16px 12px 0;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
margin-top: 1px;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
min-width: 0;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--bg-robin-400);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.descCell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 12px 0 12px 16px;
|
||||
}
|
||||
|
||||
.varDesc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--bg-sienna-400);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.typeTag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
background: var(--l2-background);
|
||||
border-radius: 10px;
|
||||
.varDescEmpty {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-left: 16px;
|
||||
background: var(--l2-background);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.row:hover .rowActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowActionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
@@ -98,3 +154,8 @@
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.applyAllButton {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface VariablesListProps {
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onApplyToAll: (index: number) => void;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
@@ -36,6 +37,7 @@ function VariablesList({
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
onApplyToAll,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
@@ -53,32 +55,39 @@ function VariablesList({
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<div className={styles.table} data-testid="variables-list">
|
||||
<div className={styles.headerRow}>
|
||||
<span className={styles.headerCell}>Variable</span>
|
||||
<span className={styles.headerCell}>Description</span>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={styles.list}>
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
onApplyToAll={onApplyToAll}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
buildSyncVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from '../applyVariableToPanelsPatch';
|
||||
|
||||
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
|
||||
function compositePanel(filterExpression: string): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/TimeSeries', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'builder_query',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A bare LIST-style BuilderQuery panel (filter lives directly on the plugin spec). */
|
||||
function listPanel(filterExpression: string): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/List', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'builder_query',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A PromQL panel — no builder filter, must be skipped. */
|
||||
function promqlPanel(): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/TimeSeries', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'promql',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/PromQLQuery', spec: { query: 'up' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the first builder query's filter expression out of a patch op's value. */
|
||||
function expressionOf(
|
||||
op: DashboardtypesJSONPatchOperationDTO,
|
||||
): string | undefined {
|
||||
const queries = op.value as Array<{
|
||||
spec: { plugin: { kind: string; spec: any } };
|
||||
}>;
|
||||
const { plugin } = queries[0].spec;
|
||||
const builderSpec =
|
||||
plugin.kind === 'signoz/CompositeQuery'
|
||||
? plugin.spec.queries[0].spec
|
||||
: plugin.spec;
|
||||
return builderSpec.filter?.expression;
|
||||
}
|
||||
|
||||
function panels(
|
||||
map: Record<string, unknown>,
|
||||
): DashboardtypesDashboardSpecDTOPanels {
|
||||
return map as DashboardtypesDashboardSpecDTOPanels;
|
||||
}
|
||||
|
||||
describe('buildApplyVariableToPanelsPatch', () => {
|
||||
it('appends the clause to an existing filter (AND-joined)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod"') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0]).toMatchObject({
|
||||
op: 'replace',
|
||||
path: '/spec/panels/p1/spec/queries',
|
||||
});
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod" AND service.name IN $svc');
|
||||
});
|
||||
|
||||
it('sets the clause when the filter is empty', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('service.name IN $svc');
|
||||
});
|
||||
|
||||
it('applies to a bare BuilderQuery (LIST) panel', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: listPanel('') }),
|
||||
'k8s.pod.name',
|
||||
'pod',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('k8s.pod.name IN $pod');
|
||||
});
|
||||
|
||||
it('is idempotent — re-applying does not duplicate the clause', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only targets the requested panels', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel(''), p2: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2'],
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe('/spec/panels/p2/spec/queries');
|
||||
});
|
||||
|
||||
it('skips PromQL panels (no builder filter)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: promqlPanel() }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns nothing when attribute or name is missing', () => {
|
||||
expect(
|
||||
buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'',
|
||||
'svc',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyncVariableToPanelsPatch', () => {
|
||||
it('adds to selected panels and removes from the rest', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({
|
||||
p1: compositePanel('service.name IN $svc'), // has it, not selected → remove
|
||||
p2: compositePanel(''), // selected → add
|
||||
p3: compositePanel('service.name IN $svc'), // has it, selected → unchanged
|
||||
}),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2', 'p3'],
|
||||
);
|
||||
const byPath = Object.fromEntries(ops.map((op) => [op.path, op]));
|
||||
expect(Object.keys(byPath).sort()).toStrictEqual([
|
||||
'/spec/panels/p1/spec/queries',
|
||||
'/spec/panels/p2/spec/queries',
|
||||
]);
|
||||
expect(expressionOf(byPath['/spec/panels/p1/spec/queries'])).toBe('');
|
||||
expect(expressionOf(byPath['/spec/panels/p2/spec/queries'])).toBe(
|
||||
'service.name IN $svc',
|
||||
);
|
||||
});
|
||||
|
||||
it('removing keeps other clauses intact', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod" AND service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
[],
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPanelIdsReferencingVariable', () => {
|
||||
it('returns only panels whose filter references the variable', () => {
|
||||
const ids = getPanelIdsReferencingVariable(
|
||||
panels({
|
||||
p1: compositePanel('service.name IN $svc'),
|
||||
p2: compositePanel('env = "prod"'),
|
||||
p3: listPanel('service.name IN $svc'),
|
||||
}),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
// Injects/removes a dynamic variable's filter (`attribute IN $name`) in panel
|
||||
// builder queries as JSON-Patch ops. Only builder queries carry a filter.
|
||||
|
||||
function clauseFor(attribute: string, variableName: string): string {
|
||||
return `${attribute} IN $${variableName}`;
|
||||
}
|
||||
|
||||
/** Runs `fn` on every builder-query spec in a panel's single query (Composite or bare Builder). */
|
||||
function forEachBuilderSpec(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
fn: (spec: Querybuildertypesv5BuilderQuerySpecDTO) => void,
|
||||
): void {
|
||||
const plugin = queries[0]?.spec?.plugin;
|
||||
if (!plugin?.spec) {
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
(composite.queries ?? [])
|
||||
.filter((envelope) => envelope.type === 'builder_query')
|
||||
.forEach((envelope) => {
|
||||
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends the clause to every builder query's filter. Returns whether anything changed. */
|
||||
function addClause(queries: DashboardtypesQueryDTO[], clause: string): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression?.trim();
|
||||
// Idempotent: a repeated apply must not stack duplicate clauses.
|
||||
if (existing?.includes(clause)) {
|
||||
return;
|
||||
}
|
||||
spec.filter = {
|
||||
expression: existing ? `${existing} AND ${clause}` : clause,
|
||||
};
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Removes the managed clause (an ` AND `-joined part) from every builder filter. */
|
||||
function removeClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const parts = existing
|
||||
.split(' AND ')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const kept = parts.filter((part) => part !== clause);
|
||||
if (kept.length !== parts.length) {
|
||||
spec.filter = { expression: kept.join(' AND ') };
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Whether any builder query in the panel already carries the clause. */
|
||||
function panelHasClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
): boolean {
|
||||
let has = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
if (spec.filter?.expression?.includes(clause)) {
|
||||
has = true;
|
||||
}
|
||||
});
|
||||
return has;
|
||||
}
|
||||
|
||||
function replaceQueriesOp(
|
||||
panelId: string,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/queries`,
|
||||
value: queries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add-only: inject the variable's filter into the given panels (default: all). */
|
||||
export function buildApplyVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
targetPanelIds?: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const ids = targetPanelIds ?? Object.keys(panels);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
ids.forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
if (addClause(queries, clause)) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Full sync: selected panels get the clause; every other panel has it removed. */
|
||||
export function buildSyncVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
selectedPanelIds: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const selected = new Set(selectedPanelIds);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
Object.keys(panels).forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
const changed = selected.has(id)
|
||||
? addClause(queries, clause)
|
||||
: removeClause(queries, clause);
|
||||
if (changed) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Panel ids whose queries currently reference the variable — pre-populates the picker. */
|
||||
export function getPanelIdsReferencingVariable(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
): string[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
return Object.keys(panels).filter((id) => {
|
||||
const queries = panels[id]?.spec?.queries;
|
||||
return queries?.length ? panelHasClause(queries, clause) : false;
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user