mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 07:30:40 +01:00
Compare commits
53 Commits
ci/better-
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6151467b3e | ||
|
|
daa999d1d6 | ||
|
|
b400846193 | ||
|
|
f5220d078f | ||
|
|
ab750ffffd | ||
|
|
4a2b508636 | ||
|
|
33795999cf | ||
|
|
9aac37eb94 | ||
|
|
12642e27da | ||
|
|
82f8bf88fc | ||
|
|
3befc9e8c6 | ||
|
|
ee5b85f7a4 | ||
|
|
a05164f225 | ||
|
|
35e4013826 | ||
|
|
5884c6aa90 | ||
|
|
533a430714 | ||
|
|
ba6af34714 | ||
|
|
851c7b0ad7 | ||
|
|
ef5a67495c | ||
|
|
9f540ca84b | ||
|
|
40a6b22aed | ||
|
|
6f16416f27 | ||
|
|
f8aa1c1c34 | ||
|
|
65835394c0 | ||
|
|
f132b7e53a | ||
|
|
d4ae156dc4 | ||
|
|
d6bdf9c2b2 | ||
|
|
7ea654f1aa | ||
|
|
3fd7d013a1 | ||
|
|
fb921dd381 | ||
|
|
58020d9e00 | ||
|
|
7a5933e822 | ||
|
|
2533683de6 | ||
|
|
2670d53170 | ||
|
|
8943a9454b | ||
|
|
9a7ed5b711 | ||
|
|
2d75e3d32d | ||
|
|
1d6eabf927 | ||
|
|
082d7b1b77 | ||
|
|
5019dee2d7 | ||
|
|
216de973fb | ||
|
|
18c0eec5e2 | ||
|
|
2ccdeb3631 | ||
|
|
ad12e50bbc | ||
|
|
e247bf3864 | ||
|
|
f4651ea134 | ||
|
|
d449a2dbf2 | ||
|
|
d4b9f91062 | ||
|
|
530710b7bc | ||
|
|
4fb5eec08d | ||
|
|
f889d36f0f | ||
|
|
db12d44523 | ||
|
|
86fc0e81ba |
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 }}
|
||||
|
||||
@@ -155,8 +155,8 @@ querier:
|
||||
cache_ttl: 168h
|
||||
# The interval for recent data that should not be cached.
|
||||
flux_interval: 5m
|
||||
# The maximum number of queries a single query range request runs at once.
|
||||
max_concurrent_queries: 8
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
|
||||
@@ -2682,7 +2682,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3649,7 +3648,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3686,7 +3684,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const IS_DEV = false;
|
||||
export const IS_PROD = true;
|
||||
export const MODE = 'test';
|
||||
@@ -29,7 +29,6 @@ const config: Config.InitialOptions = {
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
|
||||
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3395,7 +3395,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -3923,7 +3922,6 @@ export interface DashboardtypesComparisonThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -4212,7 +4210,6 @@ export interface DashboardtypesTableThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
@@ -47,11 +46,9 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { IS_DEV } from 'lib/env';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
@@ -31,33 +30,6 @@ import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
const AuthZDevModal = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
|
||||
default: m.AuthZDevModal,
|
||||
})),
|
||||
)
|
||||
: null;
|
||||
|
||||
const AuthZDevFloatingIndicator = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
|
||||
(m) => ({
|
||||
default: m.AuthZDevFloatingIndicator,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const openAuthZDevModal = IS_DEV
|
||||
? (): void => {
|
||||
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
|
||||
m.openAuthZDevModal();
|
||||
return m;
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -138,7 +110,6 @@ export function CmdKPalette({
|
||||
aiAssistant: isAIAssistantEnabled
|
||||
? { open: handleOpenAIAssistant }
|
||||
: undefined,
|
||||
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
|
||||
});
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
@@ -175,57 +146,37 @@ export function CmdKPalette({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
position="top"
|
||||
offset={110}
|
||||
>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span
|
||||
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'cmd-item-icon',
|
||||
it.id === 'ai-assistant' && 'noz-icon',
|
||||
)}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
{IS_DEV && AuthZDevModal && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevModal />
|
||||
</React.Suspense>
|
||||
)}
|
||||
{IS_DEV && AuthZDevFloatingIndicator && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevFloatingIndicator />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export enum LOCALSTORAGE {
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -43,17 +43,10 @@ type ActionDeps = {
|
||||
aiAssistant?: {
|
||||
open: () => void;
|
||||
};
|
||||
/**
|
||||
* Provided only in development mode. Opens the AuthZ DevTools modal
|
||||
* for testing permission overrides.
|
||||
*/
|
||||
authzDevTools?: {
|
||||
open: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
|
||||
const { navigate, handleThemeChange, aiAssistant } = deps;
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
@@ -309,17 +302,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (authzDevTools) {
|
||||
actions.push({
|
||||
id: 'authz-devtools',
|
||||
name: 'AuthZ DevTools',
|
||||
keywords: 'authz permissions rbac debug devtools override testing',
|
||||
section: 'Dev',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: authzDevTools.open,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -149,18 +149,6 @@
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 20px 16px 0px 16px;
|
||||
/* Preserve author-entered line breaks in the description. */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-variables {
|
||||
|
||||
@@ -43,7 +43,6 @@ import { sortLayout } from 'providers/Dashboard/util';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -516,9 +515,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">
|
||||
{linkifyText(description ?? '')}
|
||||
</section>
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
{!isEmpty(dashboardVariables) && (
|
||||
|
||||
@@ -48,8 +48,6 @@ describe('CreateRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -77,8 +77,6 @@ describe('EditRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -409,8 +409,6 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9998;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
box-shadow:
|
||||
0 4px 12px rgb(0 0 0 / 15%),
|
||||
0 0 0 1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 4px 12px rgb(0 0 0 / 15%),
|
||||
0 0 0 1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAuthZDevStore } from '../useAuthZDevStore';
|
||||
|
||||
import styles from './AuthZDevFloatingIndicator.module.css';
|
||||
|
||||
export function AuthZDevFloatingIndicator(): JSX.Element | null {
|
||||
const overrides = useAuthZDevStore((s) => s.overrides);
|
||||
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
|
||||
const openModal = useAuthZDevStore((s) => s.openModal);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const overrideCount = Object.keys(overrides).length;
|
||||
|
||||
if (overrideCount === 0 || isModalOpen || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOpen = (): void => {
|
||||
setIsDismissed(false);
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.container}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="warning"
|
||||
size="sm"
|
||||
onClick={handleOpen}
|
||||
className={styles.button}
|
||||
data-testid="authz-dev-floating-indicator"
|
||||
>
|
||||
AuthZ Overrides
|
||||
<Badge color="warning" className={styles.badge}>
|
||||
{overrideCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className={styles.closeButton}
|
||||
aria-label="Dismiss indicator"
|
||||
data-testid="authz-dev-floating-dismiss"
|
||||
prefix={<X />}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
.modal {
|
||||
--dialog-width: 640px;
|
||||
--dialog-max-width: 92vw;
|
||||
--dialog-max-height: 78vh;
|
||||
--dialog-description-padding: var(--spacing-4) var(--spacing-4) 0px
|
||||
var(--spacing-4);
|
||||
|
||||
[data-slot='dialog-description'],
|
||||
[data-slot='dialog-header'] {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(78vh - 80px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
--input-background: var(--l3-background);
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-focus-background: var(--l3-background);
|
||||
--input-border-color: var(--l3-border);
|
||||
--input-hover-border-color: var(--l3-border);
|
||||
--input-focus-border-color: var(--l3-border);
|
||||
|
||||
--select-trigger-background-color: var(--l3-background);
|
||||
--select-trigger-hover-background: var(--l3-background);
|
||||
--select-trigger-focus-background: var(--l3-background);
|
||||
--select-trigger-border-color: var(--l3-border);
|
||||
|
||||
--select-content-background: var(--l3-background);
|
||||
--select-item-highlight-background: var(--l3-background-hover);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
--input-width: 100%;
|
||||
}
|
||||
|
||||
.filter {
|
||||
flex: 0 0 176px;
|
||||
/* Normalize the library trigger height (2.25rem) to match the input. */
|
||||
--select-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.search > *,
|
||||
.filter > * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
display: inline-flex;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.actionsRow {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
flex: 0 0 auto;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-4) 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-2);
|
||||
margin: 0 0 var(--spacing-1);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.hintGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { useAuthZDevStore } from '../useAuthZDevStore';
|
||||
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
|
||||
|
||||
import { AuthZDevModalContent } from './AuthZDevModalContent';
|
||||
import { AuthZDevModalFooter } from './AuthZDevModalFooter';
|
||||
import { AuthZDevModalHeader } from './AuthZDevModalHeader';
|
||||
import { useAuthZDevModalData } from './useAuthZDevModalData';
|
||||
import { useModalKeyboard } from './useModalKeyboard';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export function AuthZDevModal(): JSX.Element | null {
|
||||
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
|
||||
const closeModal = useAuthZDevStore((s) => s.closeModal);
|
||||
const observed = useAuthZDevStore((s) => s.observed);
|
||||
const overrides = useAuthZDevStore((s) => s.overrides);
|
||||
const cycleOverride = useAuthZDevStore((s) => s.cycleOverride);
|
||||
const setOverride = useAuthZDevStore((s) => s.setOverride);
|
||||
const clearAllOverrides = useAuthZDevStore((s) => s.clearAllOverrides);
|
||||
const grantAll = useAuthZDevStore((s) => s.grantAll);
|
||||
const denyAll = useAuthZDevStore((s) => s.denyAll);
|
||||
|
||||
useAuthZQueryInvalidation(overrides);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
setResourceFilter,
|
||||
observedList,
|
||||
resourceFilterItems,
|
||||
filteredPermissions,
|
||||
groups,
|
||||
orderedPermissions,
|
||||
indexByPermission,
|
||||
hasActiveFilter,
|
||||
filteredOverrideCount,
|
||||
overrideCount,
|
||||
} = useAuthZDevModalData(observed, overrides);
|
||||
|
||||
const { selectedIndex, setSelectedIndex } = useModalKeyboard({
|
||||
permissions: orderedPermissions,
|
||||
overrides,
|
||||
onCycle: cycleOverride,
|
||||
onSetOverride: setOverride,
|
||||
onClose: closeModal,
|
||||
searchInputRef,
|
||||
});
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean): void => {
|
||||
if (!open) {
|
||||
closeModal();
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
},
|
||||
[closeModal, setSelectedIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={isModalOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="AuthZ DevTools"
|
||||
subTitle="Force permission results locally without touching the backend."
|
||||
className={styles.modal}
|
||||
width="wide"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<AuthZDevModalHeader
|
||||
searchInputRef={searchInputRef}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
resourceFilter={resourceFilter}
|
||||
setResourceFilter={setResourceFilter}
|
||||
resourceFilterItems={resourceFilterItems}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
filteredPermissions={filteredPermissions}
|
||||
filteredOverrideCount={filteredOverrideCount}
|
||||
overrideCount={overrideCount}
|
||||
grantAll={grantAll}
|
||||
denyAll={denyAll}
|
||||
clearAllOverrides={clearAllOverrides}
|
||||
/>
|
||||
<AuthZDevModalContent
|
||||
observedListLength={observedList.length}
|
||||
orderedPermissions={orderedPermissions}
|
||||
groups={groups}
|
||||
indexByPermission={indexByPermission}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
observed={observed}
|
||||
overrides={overrides}
|
||||
onSetOverride={setOverride}
|
||||
/>
|
||||
<AuthZDevModalFooter
|
||||
orderedPermissionsCount={orderedPermissions.length}
|
||||
observedListLength={observedList.length}
|
||||
/>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { ObservedPermission, OverrideState } from '../types';
|
||||
|
||||
import { PermissionRow } from './PermissionRow';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export interface PermissionGroup {
|
||||
resource: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface AuthZDevModalContentProps {
|
||||
observedListLength: number;
|
||||
orderedPermissions: string[];
|
||||
groups: PermissionGroup[];
|
||||
indexByPermission: Map<string, number>;
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
observed: Record<string, ObservedPermission>;
|
||||
overrides: Record<string, OverrideState>;
|
||||
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
}
|
||||
|
||||
export function AuthZDevModalContent({
|
||||
observedListLength,
|
||||
orderedPermissions,
|
||||
groups,
|
||||
indexByPermission,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
observed,
|
||||
overrides,
|
||||
onSetOverride,
|
||||
}: AuthZDevModalContentProps): JSX.Element {
|
||||
const handleSelectIndex = useCallback(
|
||||
(index: number) => (): void => {
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[setSelectedIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.list} data-testid="authz-dev-permission-list">
|
||||
{orderedPermissions.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text align="center" color="muted">
|
||||
{observedListLength === 0
|
||||
? 'No permissions observed yet. Navigate the app to trigger permission checks.'
|
||||
: 'No permissions match your search.'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div key={group.resource} className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Typography.Text as="span" size="medium" weight="semibold">
|
||||
{group.resource}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
{group.items.length}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{group.items.map((permission) => {
|
||||
const index = indexByPermission.get(permission) ?? 0;
|
||||
return (
|
||||
<PermissionRow
|
||||
key={permission}
|
||||
observed={observed[permission]}
|
||||
override={overrides[permission]}
|
||||
isSelected={index === selectedIndex}
|
||||
onSetOverride={onSetOverride}
|
||||
onSelect={handleSelectIndex(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Kbd } from '@signozhq/ui/kbd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export interface AuthZDevModalFooterProps {
|
||||
orderedPermissionsCount: number;
|
||||
observedListLength: number;
|
||||
}
|
||||
|
||||
export function AuthZDevModalFooter({
|
||||
orderedPermissionsCount,
|
||||
observedListLength,
|
||||
}: AuthZDevModalFooterProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.hint}>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
navigate
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>←</Kbd>
|
||||
<Kbd>→</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
mode
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>1-5</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
set
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>/</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
search
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>Esc</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
close
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</div>
|
||||
<Typography.Text size="small" color="muted" className={styles.count}>
|
||||
{orderedPermissionsCount} of {observedListLength} permissions
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export interface AuthZDevModalHeaderProps {
|
||||
searchInputRef: RefObject<HTMLInputElement>;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
resourceFilter: string;
|
||||
setResourceFilter: (value: string) => void;
|
||||
resourceFilterItems: Array<{ value: string; label: string }>;
|
||||
hasActiveFilter: boolean;
|
||||
filteredPermissions: BrandedPermission[];
|
||||
filteredOverrideCount: number;
|
||||
overrideCount: number;
|
||||
grantAll: (permissions?: BrandedPermission[]) => void;
|
||||
denyAll: (permissions?: BrandedPermission[]) => void;
|
||||
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
|
||||
}
|
||||
|
||||
export function AuthZDevModalHeader({
|
||||
searchInputRef,
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
setResourceFilter,
|
||||
resourceFilterItems,
|
||||
hasActiveFilter,
|
||||
filteredPermissions,
|
||||
filteredOverrideCount,
|
||||
overrideCount,
|
||||
grantAll,
|
||||
denyAll,
|
||||
clearAllOverrides,
|
||||
}: AuthZDevModalHeaderProps): JSX.Element {
|
||||
const handleGrantAll = useCallback((): void => {
|
||||
grantAll(hasActiveFilter ? filteredPermissions : undefined);
|
||||
}, [grantAll, hasActiveFilter, filteredPermissions]);
|
||||
|
||||
const handleDenyAll = useCallback((): void => {
|
||||
denyAll(hasActiveFilter ? filteredPermissions : undefined);
|
||||
}, [denyAll, hasActiveFilter, filteredPermissions]);
|
||||
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAllOverrides(hasActiveFilter ? filteredPermissions : undefined);
|
||||
}, [clearAllOverrides, hasActiveFilter, filteredPermissions]);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search permissions..."
|
||||
value={search}
|
||||
onChange={(e): void => setSearch(e.target.value)}
|
||||
prefix={<Search size={14} className={styles.searchIcon} />}
|
||||
aria-label="Search permissions"
|
||||
data-testid="authz-dev-search"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filter}>
|
||||
<SelectSimple
|
||||
items={resourceFilterItems}
|
||||
value={resourceFilter}
|
||||
onChange={(value): void => setResourceFilter(value as string)}
|
||||
testId="authz-dev-resource-filter"
|
||||
withPortal={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<Button
|
||||
className={styles.actionButton}
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="sm"
|
||||
onClick={handleGrantAll}
|
||||
disabled={filteredPermissions.length === 0}
|
||||
data-testid="authz-dev-grant-all"
|
||||
>
|
||||
{hasActiveFilter ? 'Grant filtered' : 'Grant all'}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.actionButton}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="sm"
|
||||
onClick={handleDenyAll}
|
||||
disabled={filteredPermissions.length === 0}
|
||||
data-testid="authz-dev-deny-all"
|
||||
>
|
||||
{hasActiveFilter ? 'Deny filtered' : 'Deny all'}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.actionButton}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
disabled={
|
||||
hasActiveFilter ? filteredOverrideCount === 0 : overrideCount === 0
|
||||
}
|
||||
data-testid="authz-dev-clear-all"
|
||||
>
|
||||
{hasActiveFilter
|
||||
? `Clear filtered (${filteredOverrideCount})`
|
||||
: `Clear all (${overrideCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.segment {
|
||||
--button-height: 22px;
|
||||
--button-padding: 0 var(--spacing-3);
|
||||
--button-secondary-ghost-hover-foreground: var(--l2-foreground);
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
border: none;
|
||||
--button-border-radius: calc(var(--radius-2) - 1px);
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.segment:not(.segmentActive):hover {
|
||||
--button-secondary-ghost-hover-foreground: var(--l2-foreground-hover);
|
||||
--button-variant-ghost-background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
.segmentIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment.optAuto {
|
||||
--button-secondary-ghost-hover-foreground: var(--l1-foreground);
|
||||
--button-variant-ghost-background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
.segment.optGranted {
|
||||
--button-secondary-ghost-hover-foreground: var(--success-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-forest) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.segment.optDenied {
|
||||
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-cherry) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.segment.optDelay {
|
||||
--button-secondary-ghost-hover-foreground: var(--warning-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-amber) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.segment.optError {
|
||||
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-cherry) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Check, Clock, RotateCcw, X, Zap } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { OverrideState } from '../types';
|
||||
|
||||
import styles from './OverrideControl.module.css';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
type OverrideControlProps = {
|
||||
permission: BrandedPermission;
|
||||
value: OverrideState;
|
||||
onSelect: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
};
|
||||
|
||||
type OverrideOption = {
|
||||
state: OverrideState;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
activeClassName: string;
|
||||
};
|
||||
|
||||
const OVERRIDE_OPTIONS: OverrideOption[] = [
|
||||
{
|
||||
state: OverrideState.Reset,
|
||||
label: 'Auto',
|
||||
icon: <RotateCcw size={13} />,
|
||||
activeClassName: styles.optAuto,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Granted,
|
||||
label: 'Grant',
|
||||
icon: <Check size={13} />,
|
||||
activeClassName: styles.optGranted,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Denied,
|
||||
label: 'Deny',
|
||||
icon: <X size={13} />,
|
||||
activeClassName: styles.optDenied,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Delay,
|
||||
label: 'Delay',
|
||||
icon: <Clock size={13} />,
|
||||
activeClassName: styles.optDelay,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Error,
|
||||
label: 'Error',
|
||||
icon: <Zap size={13} />,
|
||||
activeClassName: styles.optError,
|
||||
},
|
||||
];
|
||||
|
||||
export function OverrideControl({
|
||||
permission,
|
||||
value,
|
||||
onSelect,
|
||||
}: OverrideControlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.segmented}>
|
||||
{OVERRIDE_OPTIONS.map((option) => {
|
||||
const isActive = value === option.state;
|
||||
return (
|
||||
<Button
|
||||
key={option.state}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
className={cx(styles.segment, {
|
||||
[styles.segmentActive]: isActive,
|
||||
[option.activeClassName]: isActive,
|
||||
})}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => onSelect(permission, option.state)}
|
||||
data-testid={`override-${option.state}-${permission}`}
|
||||
>
|
||||
<div className={styles.segmentIcon}>{option.icon}</div>
|
||||
{isActive && (
|
||||
<Typography.Text as="span" size="small" weight="medium">
|
||||
{option.label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
.permissionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.permissionRow:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
/* Overridden rows carry a faint full border in the override color. */
|
||||
.permissionRow.rowGranted {
|
||||
border-color: color-mix(in srgb, var(--accent-forest) 45%, transparent);
|
||||
}
|
||||
|
||||
.permissionRow.rowDenied {
|
||||
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
|
||||
}
|
||||
|
||||
.permissionRow.rowDelay {
|
||||
border-color: color-mix(in srgb, var(--accent-amber) 45%, transparent);
|
||||
}
|
||||
|
||||
.permissionRow.rowError {
|
||||
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Keyboard selection wins over the override border. */
|
||||
.permissionRow.isSelected {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.permissionInfo {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.relation {
|
||||
flex: 0 0 auto;
|
||||
--typography-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.object {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permissionMeta {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { parsePermission } from '../../hooks/useAuthZ/utils';
|
||||
import { OverrideState, type ObservedPermission } from '../types';
|
||||
|
||||
import { OverrideControl } from './OverrideControl';
|
||||
|
||||
import styles from './PermissionRow.module.css';
|
||||
|
||||
type PermissionRowProps = {
|
||||
observed: ObservedPermission;
|
||||
override: OverrideState | undefined;
|
||||
isSelected: boolean;
|
||||
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ROW_OVERRIDE_CLASSES: Record<OverrideState, string | null> = {
|
||||
[OverrideState.Reset]: null,
|
||||
[OverrideState.Granted]: styles.rowGranted,
|
||||
[OverrideState.Denied]: styles.rowDenied,
|
||||
[OverrideState.Delay]: styles.rowDelay,
|
||||
[OverrideState.Error]: styles.rowError,
|
||||
};
|
||||
|
||||
export const PermissionRow = memo(function PermissionRow({
|
||||
observed,
|
||||
override,
|
||||
isSelected,
|
||||
onSetOverride,
|
||||
onSelect,
|
||||
}: PermissionRowProps): JSX.Element {
|
||||
const currentState = override ?? OverrideState.Reset;
|
||||
|
||||
const { relation, objectId } = useMemo(() => {
|
||||
const parsed = parsePermission(observed.permission);
|
||||
const separatorIndex = parsed.object.indexOf(':');
|
||||
return {
|
||||
relation: parsed.relation,
|
||||
objectId:
|
||||
separatorIndex === -1
|
||||
? parsed.object
|
||||
: parsed.object.slice(separatorIndex + 1),
|
||||
};
|
||||
}, [observed.permission]);
|
||||
|
||||
const handleSetOverride = useCallback(
|
||||
(permission: BrandedPermission, state: OverrideState): void => {
|
||||
onSelect();
|
||||
onSetOverride(permission, state);
|
||||
},
|
||||
[onSelect, onSetOverride],
|
||||
);
|
||||
|
||||
let apiColor: BadgeColor = 'secondary';
|
||||
let apiLabel = 'API ?';
|
||||
if (observed.apiValue === true) {
|
||||
apiColor = 'success';
|
||||
apiLabel = 'API ✓';
|
||||
} else if (observed.apiValue === false) {
|
||||
apiColor = 'error';
|
||||
apiLabel = 'API ✗';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState], {
|
||||
[styles.isSelected]: isSelected,
|
||||
})}
|
||||
data-testid={`permission-row-${observed.permission}`}
|
||||
>
|
||||
<div className={styles.permissionInfo}>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.relation}
|
||||
>
|
||||
{relation}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.separator}
|
||||
>
|
||||
:
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
truncate={1}
|
||||
className={styles.object}
|
||||
>
|
||||
{objectId}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.permissionMeta}>
|
||||
<Badge variant="outline" color={apiColor}>
|
||||
{apiLabel}
|
||||
</Badge>
|
||||
<OverrideControl
|
||||
permission={observed.permission}
|
||||
value={currentState}
|
||||
onSelect={handleSetOverride}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { parsePermission } from '../../hooks/useAuthZ/utils';
|
||||
import type { ObservedPermission, OverrideState } from '../types';
|
||||
|
||||
type SelectItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PermissionGroup = {
|
||||
resource: string;
|
||||
items: BrandedPermission[];
|
||||
};
|
||||
|
||||
type UseAuthZDevModalDataResult = {
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
resourceFilter: string;
|
||||
setResourceFilter: (filter: string) => void;
|
||||
observedList: ObservedPermission[];
|
||||
resourceFilterItems: SelectItem[];
|
||||
filteredPermissions: BrandedPermission[];
|
||||
groups: PermissionGroup[];
|
||||
orderedPermissions: BrandedPermission[];
|
||||
indexByPermission: Map<string, number>;
|
||||
hasActiveFilter: boolean;
|
||||
filteredOverrideCount: number;
|
||||
overrideCount: number;
|
||||
};
|
||||
|
||||
export function useAuthZDevModalData(
|
||||
observed: Record<string, ObservedPermission>,
|
||||
overrides: Record<string, OverrideState>,
|
||||
): UseAuthZDevModalDataResult {
|
||||
const [search, setSearch] = useState('');
|
||||
const [resourceFilter, setResourceFilter] = useState<string>('all');
|
||||
|
||||
const observedList = useMemo(
|
||||
() =>
|
||||
Object.values(observed).sort((a, b) =>
|
||||
a.permission.localeCompare(b.permission),
|
||||
),
|
||||
[observed],
|
||||
);
|
||||
|
||||
const resources = useMemo(() => {
|
||||
const resourceSet = new Set<string>();
|
||||
for (const obs of observedList) {
|
||||
const { object } = parsePermission(obs.permission);
|
||||
const resource = object.split(':')[0];
|
||||
resourceSet.add(resource);
|
||||
}
|
||||
return Array.from(resourceSet).sort();
|
||||
}, [observedList]);
|
||||
|
||||
const resourceFilterItems = useMemo<SelectItem[]>(
|
||||
() => [
|
||||
{ value: 'all', label: 'All resources' },
|
||||
...resources.map((resource) => ({
|
||||
value: resource,
|
||||
label: resource,
|
||||
})),
|
||||
],
|
||||
[resources],
|
||||
);
|
||||
|
||||
const filteredPermissions = useMemo(() => {
|
||||
let filtered = observedList;
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
filtered = filtered.filter((obs) =>
|
||||
obs.permission.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceFilter !== 'all') {
|
||||
filtered = filtered.filter((obs) => {
|
||||
const { object } = parsePermission(obs.permission);
|
||||
const resource = object.split(':')[0];
|
||||
return resource === resourceFilter;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.map((obs) => obs.permission);
|
||||
}, [observedList, search, resourceFilter]);
|
||||
|
||||
const { groups, orderedPermissions } = useMemo(() => {
|
||||
const groupMap = new Map<string, BrandedPermission[]>();
|
||||
for (const permission of filteredPermissions) {
|
||||
const { object } = parsePermission(permission);
|
||||
const resource = object.split(':')[0] || 'other';
|
||||
const bucket = groupMap.get(resource);
|
||||
if (bucket) {
|
||||
bucket.push(permission);
|
||||
} else {
|
||||
groupMap.set(resource, [permission]);
|
||||
}
|
||||
}
|
||||
|
||||
const sortItems = (items: BrandedPermission[]): BrandedPermission[] =>
|
||||
[...items].sort((a, b) => {
|
||||
const objA = parsePermission(a).object;
|
||||
const objB = parsePermission(b).object;
|
||||
const idA = objA.split(':')[1] ?? '';
|
||||
const idB = objB.split(':')[1] ?? '';
|
||||
const isWildcardA = idA === '*';
|
||||
const isWildcardB = idB === '*';
|
||||
|
||||
// Wildcards first
|
||||
if (isWildcardA && !isWildcardB) {
|
||||
return -1;
|
||||
}
|
||||
if (!isWildcardA && isWildcardB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then by object ID, then by full permission
|
||||
const idCompare = idA.localeCompare(idB);
|
||||
if (idCompare !== 0) {
|
||||
return idCompare;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const sortedGroups = Array.from(groupMap, ([resource, items]) => ({
|
||||
resource,
|
||||
items: sortItems(items),
|
||||
})).sort((a, b) => a.resource.localeCompare(b.resource));
|
||||
return {
|
||||
groups: sortedGroups,
|
||||
orderedPermissions: sortedGroups.flatMap((group) => group.items),
|
||||
};
|
||||
}, [filteredPermissions]);
|
||||
|
||||
const indexByPermission = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
orderedPermissions.forEach((permission, index) => {
|
||||
map.set(permission, index);
|
||||
});
|
||||
return map;
|
||||
}, [orderedPermissions]);
|
||||
|
||||
const hasActiveFilter = search !== '' || resourceFilter !== 'all';
|
||||
|
||||
const filteredOverrideCount = useMemo(() => {
|
||||
if (!hasActiveFilter) {
|
||||
return Object.keys(overrides).length;
|
||||
}
|
||||
return filteredPermissions.filter((p) => p in overrides).length;
|
||||
}, [hasActiveFilter, overrides, filteredPermissions]);
|
||||
|
||||
const overrideCount = Object.keys(overrides).length;
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
setResourceFilter,
|
||||
observedList,
|
||||
resourceFilterItems,
|
||||
filteredPermissions,
|
||||
groups,
|
||||
orderedPermissions,
|
||||
indexByPermission,
|
||||
hasActiveFilter,
|
||||
filteredOverrideCount,
|
||||
overrideCount,
|
||||
};
|
||||
}
|
||||
@@ -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,46 +0,0 @@
|
||||
import type { BrandedPermission } from '../hooks/useAuthZ/types';
|
||||
|
||||
export enum OverrideState {
|
||||
Granted = 'granted',
|
||||
Denied = 'denied',
|
||||
Delay = 'delay',
|
||||
Error = 'error',
|
||||
Reset = 'reset',
|
||||
}
|
||||
|
||||
export type ObservedPermission = {
|
||||
permission: BrandedPermission;
|
||||
apiValue: boolean | null;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
export type PermissionOverride = {
|
||||
permission: BrandedPermission;
|
||||
state: OverrideState;
|
||||
};
|
||||
|
||||
export type AuthZDevStore = {
|
||||
isModalOpen: boolean;
|
||||
observed: Record<string, ObservedPermission>;
|
||||
overrides: Record<string, OverrideState>;
|
||||
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
toggleModal: () => void;
|
||||
|
||||
registerObserved: (permission: BrandedPermission, apiValue: boolean) => void;
|
||||
setOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
clearOverride: (permission: BrandedPermission) => void;
|
||||
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
|
||||
grantAll: (permissions?: BrandedPermission[]) => void;
|
||||
denyAll: (permissions?: BrandedPermission[]) => void;
|
||||
cycleOverride: (permission: BrandedPermission) => void;
|
||||
};
|
||||
|
||||
export const OVERRIDE_CYCLE: OverrideState[] = [
|
||||
OverrideState.Reset,
|
||||
OverrideState.Granted,
|
||||
OverrideState.Denied,
|
||||
OverrideState.Delay,
|
||||
OverrideState.Error,
|
||||
];
|
||||
@@ -1,137 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import type { BrandedPermission } from '../hooks/useAuthZ/types';
|
||||
import { OverrideState, OVERRIDE_CYCLE, type AuthZDevStore } from './types';
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
export const useAuthZDevStore = create<AuthZDevStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isModalOpen: false,
|
||||
observed: {},
|
||||
overrides: {},
|
||||
|
||||
openModal: (): void => {
|
||||
set({ isModalOpen: true });
|
||||
},
|
||||
|
||||
closeModal: (): void => {
|
||||
set({ isModalOpen: false });
|
||||
},
|
||||
|
||||
toggleModal: (): void => {
|
||||
set((state) => ({ isModalOpen: !state.isModalOpen }));
|
||||
},
|
||||
|
||||
registerObserved: (
|
||||
permission: BrandedPermission,
|
||||
apiValue: boolean,
|
||||
): void => {
|
||||
set((state) => ({
|
||||
observed: {
|
||||
...state.observed,
|
||||
[permission]: {
|
||||
permission,
|
||||
apiValue,
|
||||
lastSeen: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setOverride: (permission: BrandedPermission, state: OverrideState): void => {
|
||||
if (state === OverrideState.Reset) {
|
||||
get().clearOverride(permission);
|
||||
return;
|
||||
}
|
||||
set((s) => ({
|
||||
overrides: {
|
||||
...s.overrides,
|
||||
[permission]: state,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearOverride: (permission: BrandedPermission): void => {
|
||||
set((state) => {
|
||||
const { [permission]: _, ...rest } = state.overrides;
|
||||
return { overrides: rest };
|
||||
});
|
||||
},
|
||||
|
||||
clearAllOverrides: (permissions?: BrandedPermission[]): void => {
|
||||
if (permissions) {
|
||||
set((state) => {
|
||||
const newOverrides = { ...state.overrides };
|
||||
for (const permission of permissions) {
|
||||
delete newOverrides[permission];
|
||||
}
|
||||
return { overrides: newOverrides };
|
||||
});
|
||||
} else {
|
||||
set({ overrides: {} });
|
||||
}
|
||||
},
|
||||
|
||||
grantAll: (permissions?: BrandedPermission[]): void => {
|
||||
set((state) => {
|
||||
const keys = permissions ?? Object.keys(state.observed);
|
||||
const newOverrides: Record<string, OverrideState> = {
|
||||
...state.overrides,
|
||||
};
|
||||
for (const key of keys) {
|
||||
newOverrides[key] = OverrideState.Granted;
|
||||
}
|
||||
return { overrides: newOverrides };
|
||||
});
|
||||
},
|
||||
|
||||
denyAll: (permissions?: BrandedPermission[]): void => {
|
||||
set((state) => {
|
||||
const keys = permissions ?? Object.keys(state.observed);
|
||||
const newOverrides: Record<string, OverrideState> = {
|
||||
...state.overrides,
|
||||
};
|
||||
for (const key of keys) {
|
||||
newOverrides[key] = OverrideState.Denied;
|
||||
}
|
||||
return { overrides: newOverrides };
|
||||
});
|
||||
},
|
||||
|
||||
cycleOverride: (permission: BrandedPermission): void => {
|
||||
const currentOverride = get().overrides[permission] ?? OverrideState.Reset;
|
||||
const currentIndex = OVERRIDE_CYCLE.indexOf(currentOverride);
|
||||
const nextIndex = (currentIndex + 1) % OVERRIDE_CYCLE.length;
|
||||
const nextState = OVERRIDE_CYCLE[nextIndex];
|
||||
get().setOverride(permission, nextState);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: `@signoz/${getScopedKey('authz-dev-overrides')}`,
|
||||
partialize: (state) => {
|
||||
// Clear apiValue for permissions without active override (auto mode)
|
||||
// since the API value can change between sessions
|
||||
const observed: typeof state.observed = {};
|
||||
for (const [key, obs] of Object.entries(state.observed)) {
|
||||
observed[key] = {
|
||||
...obs,
|
||||
apiValue: key in state.overrides ? obs.apiValue : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
observed,
|
||||
overrides: state.overrides,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const openAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().openModal();
|
||||
export const closeAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().closeModal();
|
||||
export const toggleAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().toggleModal();
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import type { OverrideState } from './types';
|
||||
|
||||
type Overrides = Record<string, OverrideState>;
|
||||
|
||||
export function useAuthZQueryInvalidation(overrides: Overrides): void {
|
||||
const queryClient = useQueryClient();
|
||||
const prevOverridesRef = useRef<Overrides>(overrides);
|
||||
|
||||
useEffect(() => {
|
||||
const prevOverrides = prevOverridesRef.current;
|
||||
prevOverridesRef.current = overrides;
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(prevOverrides),
|
||||
...Object.keys(overrides),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (prevOverrides[key] !== overrides[key]) {
|
||||
// Reset query to initial state and trigger refetch for active observers
|
||||
void queryClient.resetQueries(['authz', key]);
|
||||
}
|
||||
}
|
||||
}, [overrides, queryClient]);
|
||||
}
|
||||
@@ -89,13 +89,5 @@ export type UseAuthZResult = {
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
permissions: AuthZCheckResponse | null;
|
||||
/**
|
||||
* True if every check is granted. False while loading or on error.
|
||||
*/
|
||||
allowed: boolean;
|
||||
/**
|
||||
* Checks that resolved as not granted (empty while loading/error).
|
||||
*/
|
||||
deniedPermissions: BrandedPermission[];
|
||||
refetchPermissions: () => void;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
@@ -47,16 +46,12 @@ describe('useAuthZ', () => {
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.permissions).toBeNull();
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.permissions).toStrictEqual(expectedResponse);
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([permission2]);
|
||||
});
|
||||
|
||||
it('should return error and null permissions when API errors', async () => {
|
||||
@@ -78,89 +73,6 @@ describe('useAuthZ', () => {
|
||||
|
||||
expect(result.current.error).not.toBeNull();
|
||||
expect(result.current.permissions).toBeNull();
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should set allowed to true when all permissions are granted', async () => {
|
||||
const permission1 = buildPermission('read', 'role:*');
|
||||
const permission2 = buildPermission('update', 'role:123');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [true, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.allowed).toBe(true);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should collect all denied permissions when multiple are denied', async () => {
|
||||
const permission1 = buildPermission('read', 'role:*');
|
||||
const permission2 = buildPermission('update', 'role:123');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([
|
||||
permission1,
|
||||
permission2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not fetch when enabled is false', async () => {
|
||||
let requestCount = 0;
|
||||
const permission = buildPermission('read', 'role:*');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAuthZ([permission], { enabled: false }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(0);
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.permissions).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('should refetch when permissions array changes', async () => {
|
||||
@@ -562,120 +474,3 @@ describe('useAuthZ', () => {
|
||||
expect(result2.current.permissions).not.toHaveProperty(permission1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAuthZ cache invalidation', () => {
|
||||
it('should re-render with updated data when query is invalidated', async () => {
|
||||
const permission = buildPermission('read', 'role:*');
|
||||
|
||||
let requestCount = 0;
|
||||
let shouldGrant = true;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const queryClient = useQueryClient();
|
||||
const authz = useAuthZ([permission]);
|
||||
return { authz, queryClient };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authz.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(result.current.authz.allowed).toBe(true);
|
||||
expect(result.current.authz.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: true },
|
||||
});
|
||||
|
||||
// Change server response and reset query (forces refetch)
|
||||
shouldGrant = false;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authz.allowed).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.current.authz.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-render all components using the same permission when invalidated', async () => {
|
||||
const permission = buildPermission('update', 'role:123');
|
||||
|
||||
let requestCount = 0;
|
||||
let shouldGrant = true;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Two separate hooks using the same permission
|
||||
const { result: result1 } = renderHook(
|
||||
() => {
|
||||
const queryClient = useQueryClient();
|
||||
const authz = useAuthZ([permission]);
|
||||
return { authz, queryClient };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result: result2 } = renderHook(() => useAuthZ([permission]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result1.current.authz.isLoading).toBe(false);
|
||||
expect(result2.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Both should show granted, single batched request
|
||||
expect(requestCount).toBe(1);
|
||||
expect(result1.current.authz.allowed).toBe(true);
|
||||
expect(result2.current.allowed).toBe(true);
|
||||
|
||||
// Change server response and reset query (forces refetch)
|
||||
shouldGrant = false;
|
||||
|
||||
await act(async () => {
|
||||
await result1.current.queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
|
||||
// Both hooks should update
|
||||
await waitFor(() => {
|
||||
expect(result1.current.authz.allowed).toBe(false);
|
||||
expect(result2.current.allowed).toBe(false);
|
||||
});
|
||||
|
||||
expect(result1.current.authz.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: false },
|
||||
});
|
||||
expect(result2.current.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { authzCheck } from 'api/generated/services/authz';
|
||||
import type {
|
||||
CoretypesObjectDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IS_DEV, MODE } from 'lib/env';
|
||||
|
||||
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
|
||||
import type {
|
||||
import {
|
||||
AuthZCheckResponse,
|
||||
BrandedPermission,
|
||||
UseAuthZOptions,
|
||||
@@ -19,59 +17,6 @@ import {
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
} from './utils';
|
||||
import { OverrideState } from '../../devtools/types';
|
||||
|
||||
let devStoreRef:
|
||||
| typeof import('../../devtools/useAuthZDevStore').useAuthZDevStore
|
||||
| null = null;
|
||||
|
||||
if (IS_DEV) {
|
||||
void import('../../devtools/useAuthZDevStore').then((mod) => {
|
||||
devStoreRef = mod.useAuthZDevStore;
|
||||
return mod;
|
||||
});
|
||||
}
|
||||
|
||||
const DEV_DELAY_MS = 2000;
|
||||
|
||||
function getDevOverride(permission: BrandedPermission): OverrideState | null {
|
||||
if (!IS_DEV || !devStoreRef) {
|
||||
return null;
|
||||
}
|
||||
return devStoreRef.getState().overrides[permission] ?? null;
|
||||
}
|
||||
|
||||
async function applyDevOverrideToQuery(
|
||||
permission: BrandedPermission,
|
||||
fetchFn: () => Promise<AuthZCheckResponse>,
|
||||
): Promise<AuthZCheckResponse> {
|
||||
const override = getDevOverride(permission);
|
||||
|
||||
if (override === OverrideState.Error) {
|
||||
throw new Error(`[AuthZ DevTools] Simulated error for: ${permission}`);
|
||||
}
|
||||
|
||||
if (override === OverrideState.Delay) {
|
||||
await new Promise((resolve) => setTimeout(resolve, DEV_DELAY_MS));
|
||||
}
|
||||
|
||||
const response = await fetchFn();
|
||||
|
||||
if (IS_DEV && devStoreRef) {
|
||||
const apiValue = response[permission]?.isGranted ?? false;
|
||||
devStoreRef.getState().registerObserved(permission, apiValue);
|
||||
}
|
||||
|
||||
if (override === OverrideState.Granted) {
|
||||
return { [permission]: { isGranted: true } };
|
||||
}
|
||||
|
||||
if (override === OverrideState.Denied) {
|
||||
return { [permission]: { isGranted: false } };
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
let ctx: Promise<AuthZCheckResponse> | null;
|
||||
let pendingPermissions: BrandedPermission[] = [];
|
||||
@@ -82,11 +27,10 @@ function dispatchPermission(
|
||||
pendingPermissions.push(permission);
|
||||
|
||||
if (!ctx) {
|
||||
let promiseResolve: (v: AuthZCheckResponse) => void,
|
||||
promiseReject: (reason?: unknown) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((resolve, reject) => {
|
||||
promiseResolve = resolve;
|
||||
promiseReject = reject;
|
||||
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((r, re) => {
|
||||
resolve = r;
|
||||
reject = re;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -94,9 +38,7 @@ function dispatchPermission(
|
||||
pendingPermissions = [];
|
||||
ctx = null;
|
||||
|
||||
fetchManyPermissions(copiedPermissions)
|
||||
.then(promiseResolve)
|
||||
.catch(promiseReject);
|
||||
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
|
||||
}, SINGLE_FLIGHT_WAIT_TIME_MS);
|
||||
}
|
||||
|
||||
@@ -143,50 +85,19 @@ export function useAuthZ(
|
||||
return {
|
||||
queryKey: ['authz', permission],
|
||||
cacheTime: AUTHZ_CACHE_TIME,
|
||||
staleTime: AUTHZ_CACHE_TIME,
|
||||
// Keep errored state in cache instead of refetching when new observers subscribe
|
||||
retryOnMount: false,
|
||||
// Only override retry in non-test mode to avoid interfering with test-utils QueryClient defaults
|
||||
...(MODE !== 'test' && {
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
// Don't retry simulated dev errors - they will always fail
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('[AuthZ DevTools]')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Don't retry server errors (5xx) - they won't recover
|
||||
if (
|
||||
isAxiosError(error) &&
|
||||
error.response?.status &&
|
||||
error.response.status >= 500
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
}),
|
||||
refetchOnMount: false,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
queryFn: async (): Promise<AuthZCheckResponse> => {
|
||||
const fetchFn = async (): Promise<AuthZCheckResponse> => {
|
||||
const response = await dispatchPermission(permission);
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
};
|
||||
const response = await dispatchPermission(permission);
|
||||
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
};
|
||||
|
||||
if (IS_DEV) {
|
||||
return applyDevOverrideToQuery(permission, fetchFn);
|
||||
}
|
||||
|
||||
return fetchFn();
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -196,7 +107,6 @@ export function useAuthZ(
|
||||
() => queryResults.some((q) => q.isLoading),
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
const isFetching = useMemo(
|
||||
() => queryResults.some((q) => q.isFetching),
|
||||
[queryResults],
|
||||
@@ -229,31 +139,15 @@ export function useAuthZ(
|
||||
|
||||
const refetchPermissions = useCallback(() => {
|
||||
for (const query of queryResults) {
|
||||
void query.refetch();
|
||||
query.refetch();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
const allowed = useMemo(() => {
|
||||
if (isLoading || error || !data) {
|
||||
return false;
|
||||
}
|
||||
return permissions.every((check) => data[check]?.isGranted === true);
|
||||
}, [permissions, data, isLoading, error]);
|
||||
|
||||
const deniedPermissions = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return permissions.filter((check) => data[check]?.isGranted !== true);
|
||||
}, [permissions, data]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
permissions: data ?? null,
|
||||
allowed,
|
||||
deniedPermissions,
|
||||
refetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,8 +168,6 @@ export function mockUseAuthZGrantAll(
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: true }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: true,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -185,8 +183,6 @@ export function mockUseAuthZDenyAll(
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: false,
|
||||
deniedPermissions: permissions,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -197,23 +193,16 @@ export function mockUseAuthZGrantByPrefix(
|
||||
permissions: BrandedPermission[],
|
||||
options?: UseAuthZOptions,
|
||||
) => UseAuthZResult {
|
||||
return (permissions, _options) => {
|
||||
const denied = permissions.filter(
|
||||
(p) => !prefixes.some((prefix) => p.startsWith(prefix)),
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [
|
||||
p,
|
||||
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
|
||||
]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
};
|
||||
return (permissions, _options) => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [
|
||||
p,
|
||||
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
|
||||
]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
export const IS_PROD = import.meta.env.PROD;
|
||||
export const MODE = import.meta.env.MODE;
|
||||
@@ -32,22 +32,6 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.descriptionTooltip {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
/* Preserve author-entered line breaks in the description. */
|
||||
white-space: pre-wrap;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,7 +14,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
@@ -144,14 +143,7 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple
|
||||
side="bottom"
|
||||
title={
|
||||
<span className={styles.descriptionTooltip}>
|
||||
{linkifyText(description)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TooltipSimple title={description} disableHoverableContent>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
|
||||
@@ -48,13 +48,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footerStatus {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -64,32 +57,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danglingWarning {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.warningText {
|
||||
overflow: hidden;
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Above the JSON drawer (antd Drawer sits at ~1100) so the id list is visible.
|
||||
.warningTooltip {
|
||||
z-index: 1101;
|
||||
}
|
||||
|
||||
.validationValid {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { KeyboardEvent, useCallback } from 'react';
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Drawer } from 'antd';
|
||||
@@ -28,18 +26,8 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
|
||||
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -60,7 +48,6 @@ function JsonEditorDrawer({
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
event.stopPropagation();
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
@@ -73,19 +60,6 @@ function JsonEditorDrawer({
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
const plural = (n: number): string => (n === 1 ? '' : 's');
|
||||
const danglingWarning =
|
||||
danglingPanelIds.length > 0
|
||||
? `${danglingPanelIds.length} panel${plural(
|
||||
danglingPanelIds.length,
|
||||
)} not present in layout — they won't be shown after saving.`
|
||||
: null;
|
||||
const missingRefWarning =
|
||||
missingPanelRefs.length > 0
|
||||
? `${missingPanelRefs.length} layout item${plural(
|
||||
missingPanelRefs.length,
|
||||
)} ${missingPanelRefs.length === 1 ? 'references' : 'reference'} a panel that no longer exists.`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -97,49 +71,15 @@ function JsonEditorDrawer({
|
||||
rootClassName={styles.root}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerStatus}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
{danglingWarning && (
|
||||
<TooltipSimple
|
||||
title={danglingPanelIds.join(', ')}
|
||||
tooltipContentProps={{ className: styles.warningTooltip }}
|
||||
>
|
||||
<span
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-dangling-warning"
|
||||
>
|
||||
<TriangleAlert size={12} className={styles.warningIcon} />
|
||||
<Typography.Text className={styles.warningText}>
|
||||
{danglingWarning}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{missingRefWarning && (
|
||||
<TooltipSimple
|
||||
title={missingPanelRefs.join(', ')}
|
||||
tooltipContentProps={{ className: styles.warningTooltip }}
|
||||
>
|
||||
<span
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-missing-ref-warning"
|
||||
>
|
||||
<TriangleAlert size={12} className={styles.warningIcon} />
|
||||
<Typography.Text className={styles.warningText}>
|
||||
{missingRefWarning}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer';
|
||||
@@ -49,13 +48,11 @@ function hookValue(
|
||||
validity: { valid: true, lineCount: 3 },
|
||||
isDirty: true,
|
||||
isSaving: false,
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
format: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
apply: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as ReturnType<typeof useJsonEditor>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
@@ -84,42 +81,6 @@ describe('JsonEditorDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about dangling panels, and hides the warning when there are none', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
|
||||
'2 panels not present in layout',
|
||||
);
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('json-editor-dangling-warning'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns about layout refs to missing panels', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('json-editor-missing-ref-warning'),
|
||||
).toHaveTextContent('1 layout item references a panel that no longer exists');
|
||||
});
|
||||
|
||||
it('shows the error line and message when invalid', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findPanelLayoutIssues } from '../danglingPanels';
|
||||
|
||||
const grid = (refs: string[]): unknown => ({
|
||||
kind: 'Grid',
|
||||
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
|
||||
});
|
||||
|
||||
// Cast a loose fixture to the spec type — the helper is defensive against the
|
||||
// untrusted, hand-edited JSON it runs on.
|
||||
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
|
||||
value as DashboardtypesDashboardSpecDTO;
|
||||
|
||||
describe('findPanelLayoutIssues', () => {
|
||||
it('flags nothing when panels and layouts agree', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
|
||||
});
|
||||
|
||||
it('lists panels placed in no layout as dangling', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {}, c: {} },
|
||||
layouts: [grid(['#/spec/panels/a'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
|
||||
expect(result.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('treats a removed/empty layout as orphaning every panel', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({ panels: { a: {}, b: {} }, layouts: [] }),
|
||||
).danglingPanelIds.sort(),
|
||||
).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('lists layout refs to a panel that no longer exists as missing', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds).toStrictEqual([]);
|
||||
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
});
|
||||
|
||||
it('handles empty / malformed specs', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
|
||||
).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -203,47 +203,4 @@ describe('useJsonEditor', () => {
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
});
|
||||
|
||||
it('reports panels not placed in any layout as dangling', () => {
|
||||
const withDangling = {
|
||||
...dashboard,
|
||||
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withDangling,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
|
||||
expect(result.current.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('reports layout refs to panels that no longer exist as missing', () => {
|
||||
const withMissing = {
|
||||
...dashboard,
|
||||
spec: {
|
||||
...dashboard.spec,
|
||||
panels: {},
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withMissing,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
expect(result.current.danglingPanelIds).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { extractPanelIdFromRef } from '../../utils';
|
||||
|
||||
export interface PanelLayoutIssues {
|
||||
// Panels defined in `spec.panels` that no layout places — they render nowhere.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout item references that no longer exist in `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
}
|
||||
|
||||
const referencedPanelIds = (
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): Set<string> => {
|
||||
const referenced = new Set<string>();
|
||||
layouts.forEach((layout) => {
|
||||
if (layout?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
(layout.spec?.items ?? []).forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item?.content?.$ref);
|
||||
if (id) {
|
||||
referenced.add(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
return referenced;
|
||||
};
|
||||
|
||||
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
|
||||
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
|
||||
// removed (broken reference). Guarded for untrusted, user-edited JSON.
|
||||
export function findPanelLayoutIssues(
|
||||
spec: DashboardtypesDashboardSpecDTO | undefined,
|
||||
): PanelLayoutIssues {
|
||||
const panels = spec?.panels ?? {};
|
||||
const panelIds = Object.keys(panels);
|
||||
const referenced = referencedPanelIds(spec?.layouts ?? []);
|
||||
|
||||
return {
|
||||
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
|
||||
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -32,10 +28,6 @@ interface Result {
|
||||
validity: JsonValidity;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout references that are missing from the draft's `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
format: () => void;
|
||||
reset: () => void;
|
||||
apply: () => Promise<void>;
|
||||
@@ -117,23 +109,6 @@ export function useJsonEditor({
|
||||
|
||||
const isDirty = draft !== appliedText;
|
||||
|
||||
const { danglingPanelIds, missingPanelRefs } = useMemo<{
|
||||
danglingPanelIds: string[];
|
||||
missingPanelRefs: string[];
|
||||
}>(() => {
|
||||
if (!validity.valid) {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(draft) as {
|
||||
spec?: DashboardtypesDashboardSpecDTO;
|
||||
};
|
||||
return findPanelLayoutIssues(parsed.spec);
|
||||
} catch {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
}, [draft, validity.valid]);
|
||||
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
@@ -163,7 +138,7 @@ export function useJsonEditor({
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -184,8 +159,6 @@ export function useJsonEditor({
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
|
||||
@@ -63,13 +63,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -188,7 +183,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
@@ -55,32 +54,20 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const op = (
|
||||
operation: DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({ op: operation, path, value });
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO =>
|
||||
op(DashboardtypesPatchOpDTO.replace, path, value);
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title && updatedTitle !== '') {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
// `replace` fails when the description doesn't exist yet, so add it when
|
||||
// the current one is empty (`add` creates or replaces the member).
|
||||
ops.push(
|
||||
op(
|
||||
description
|
||||
? DashboardtypesPatchOpDTO.replace
|
||||
: DashboardtypesPatchOpDTO.add,
|
||||
'/spec/display/description',
|
||||
updatedDescription,
|
||||
),
|
||||
);
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
|
||||
@@ -1,69 +1,22 @@
|
||||
.panelTypeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelTypeCard {
|
||||
.typeButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l1-foreground);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background-hover);
|
||||
border-color: var(--bg-robin-400);
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.panelTypeCardSelected {
|
||||
border-color: var(--bg-robin-400);
|
||||
background-color: var(--l2-background-hover);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerPicker {
|
||||
// Take all the width left over by the (natural-width) confirm button.
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pickerLabel {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,93 +1,45 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from './constants';
|
||||
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
/** Section the picker opens on; omit → the first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
onSelect: (panelKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
defaultLayoutIndex,
|
||||
}: PanelTypeSelectionModalProps): JSX.Element {
|
||||
const sections = useDashboardSections();
|
||||
const options = useMemo(() => buildSectionOptions(sections), [sections]);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState('');
|
||||
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Seed the target section on open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
|
||||
setSelectedPanelKind(null);
|
||||
}
|
||||
}, [open, options, defaultLayoutIndex]);
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (selectedPanelKind === null) {
|
||||
return;
|
||||
}
|
||||
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
|
||||
onSelect(selectedPanelKind, layoutIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="New Panel"
|
||||
footer={
|
||||
<PanelTypeSelectionModalFooter
|
||||
options={options}
|
||||
selectedValue={selectedValue}
|
||||
onSectionChange={setSelectedValue}
|
||||
isConfirmDisabled={selectedPanelKind === null}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.panelTypeSection}>
|
||||
<span className={styles.pickerLabel}>Select panel type</span>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<button
|
||||
key={panelKind}
|
||||
type="button"
|
||||
className={cx(styles.panelTypeCard, {
|
||||
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
|
||||
})}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
aria-pressed={panelKind === selectedPanelKind}
|
||||
onClick={(): void => setSelectedPanelKind(panelKind)}
|
||||
>
|
||||
<Icon size={24} color={Color.BG_ROBIN_400} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<Button
|
||||
key={panelKind}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
onClick={(): void => onSelect(panelKind)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import SectionPicker from './SectionPicker';
|
||||
import type { SectionOption } from './types';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
|
||||
interface PanelTypeSelectionModalFooterProps {
|
||||
options: SectionOption[];
|
||||
selectedValue: string;
|
||||
onSectionChange: (value: string) => void;
|
||||
/** Disabled until a panel type is picked. */
|
||||
isConfirmDisabled: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer for the New Panel modal: an "Add panel to" section picker (shown only
|
||||
* when the dashboard has more than one section) and the confirm button.
|
||||
*/
|
||||
function PanelTypeSelectionModalFooter({
|
||||
options,
|
||||
selectedValue,
|
||||
onSectionChange,
|
||||
isConfirmDisabled,
|
||||
onConfirm,
|
||||
}: PanelTypeSelectionModalFooterProps): JSX.Element {
|
||||
const hasSectionPicker = options.length > 1;
|
||||
|
||||
return (
|
||||
<div className={styles.footerActions}>
|
||||
{hasSectionPicker && (
|
||||
<div className={styles.footerPicker}>
|
||||
<span className={styles.pickerLabel}>Add panel to</span>
|
||||
<SectionPicker
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={onSectionChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={isConfirmDisabled}
|
||||
prefix={<Plus size={16} />}
|
||||
onClick={onConfirm}
|
||||
testId="panel-type-confirm"
|
||||
>
|
||||
Add Panel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSelectionModalFooter;
|
||||
@@ -1,55 +0,0 @@
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.rootIcon {
|
||||
color: var(--bg-robin-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.triggerValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.optionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
color: var(--l1-foreground);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.optionDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select } from 'antd';
|
||||
|
||||
import type { SectionOption } from './types';
|
||||
import styles from './SectionPicker.module.scss';
|
||||
|
||||
interface SectionPickerProps {
|
||||
options: SectionOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SectionPicker({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: SectionPickerProps): JSX.Element {
|
||||
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
options.map((option) => {
|
||||
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
|
||||
return {
|
||||
value: option.value,
|
||||
selectedLabel: (
|
||||
<span className={styles.triggerValue}>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.triggerLabel}>{option.label}</span>
|
||||
</span>
|
||||
),
|
||||
label: (
|
||||
<span
|
||||
className={styles.optionRow}
|
||||
data-testid={`panel-section-option-${option.layoutIndex}`}
|
||||
>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.optionText}>
|
||||
<span className={styles.optionLabel}>{option.label}</span>
|
||||
<span className={styles.optionDescription}>{option.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}),
|
||||
[options],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select<string>
|
||||
className={styles.select}
|
||||
popupClassName={styles.dropdown}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="panel-section-select"
|
||||
optionLabelProp="selectedLabel"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
options={selectOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionPicker;
|
||||
@@ -14,16 +14,3 @@ export interface PanelType {
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export interface SectionOption {
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
value: string;
|
||||
layoutIndex: number;
|
||||
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
|
||||
label: string;
|
||||
/** Caption under the label. */
|
||||
description: string;
|
||||
/** Untitled top-level layout (has no section header). */
|
||||
isRoot: boolean;
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionOption } from './types';
|
||||
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
const ROOT_DESCRIPTION = 'Top level — no section';
|
||||
const SECTION_DESCRIPTION = 'Section';
|
||||
|
||||
/** Maps dashboard sections to section-picker options (untitled → "root"). */
|
||||
export function buildSectionOptions(
|
||||
sections: DashboardSection[],
|
||||
): SectionOption[] {
|
||||
return sections.map((section) => {
|
||||
const isRoot = !section.title && section.layoutIndex === 0;
|
||||
return {
|
||||
value: String(section.layoutIndex),
|
||||
layoutIndex: section.layoutIndex,
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
|
||||
isRoot,
|
||||
Icon: isRoot ? LayoutDashboard : Rows2,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the option the picker should open on: the section the "Add panel" was
|
||||
* triggered from when present and still valid, otherwise the first option.
|
||||
*/
|
||||
export function resolveDefaultSectionValue(
|
||||
options: SectionOption[],
|
||||
defaultLayoutIndex: number | undefined,
|
||||
): string {
|
||||
const fallback = options[0]?.value ?? '';
|
||||
if (defaultLayoutIndex === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const target = String(defaultLayoutIndex);
|
||||
return options.some((option) => option.value === target) ? target : fallback;
|
||||
}
|
||||
@@ -29,13 +29,8 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
@@ -146,7 +141,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -12,10 +12,7 @@ interface UseCreatePanelResult {
|
||||
/** Pass the target section's layout index; omit → last/new section. */
|
||||
openPicker: (layoutIndex?: number) => void;
|
||||
closePicker: () => void;
|
||||
/** The section the picker was opened against — seeds its section dropdown. */
|
||||
targetLayoutIndex: number | undefined;
|
||||
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
|
||||
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
createPanel: (panelKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,23 +38,16 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
(panelKind: PanelKind): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
targetLayoutIndex: layoutIndex,
|
||||
createPanel,
|
||||
};
|
||||
return { isPickerOpen, openPicker, closePicker, createPanel };
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { type DashboardSection, layoutsToSections } from '../utils';
|
||||
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
|
||||
/**
|
||||
* The current dashboard's sections, derived from the guaranteed-loaded dashboard
|
||||
* via useDashboardFetchRequired. That reuses the shared react-query cache (no extra
|
||||
* request) instead of prop-drilling the section list.
|
||||
*/
|
||||
export function useDashboardSections(): DashboardSection[] {
|
||||
const { dashboard } = useDashboardFetchRequired();
|
||||
const spec = dashboard.spec;
|
||||
|
||||
return useMemo(
|
||||
() => layoutsToSections(spec.layouts, spec.panels),
|
||||
[spec.layouts, spec.panels],
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import styles from './DashboardsList.module.scss';
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
isModified: boolean;
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
@@ -15,7 +14,6 @@ interface Props {
|
||||
function CommandHeader({
|
||||
label,
|
||||
count,
|
||||
isModified,
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: Props): JSX.Element {
|
||||
@@ -23,7 +21,6 @@ function CommandHeader({
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
|
||||
@@ -93,14 +93,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -10,9 +10,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { combineQueries } from '../../utils/filterQuery';
|
||||
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
usePage,
|
||||
@@ -25,6 +25,7 @@ import { BuiltinViewId } from '../../types';
|
||||
import type { SelectedTag, UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { applyClientView } from '../../utils/views';
|
||||
import type { CreatorOption } from '../FilterZone/FilterChips';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
import StatusBar from '../StatusBar/StatusBar';
|
||||
@@ -71,13 +72,13 @@ function DashboardsList(): JSX.Element {
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
viewQuery,
|
||||
clientView,
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
renameView,
|
||||
} = useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
@@ -152,13 +153,13 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: query || undefined,
|
||||
query: combineQueries(viewQuery, query) || undefined,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
|
||||
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[query, sortColumn, sortOrder, page, clientView],
|
||||
[viewQuery, query, sortColumn, sortOrder, page, clientView],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -194,19 +195,24 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Authors present on the loaded page — a fallback for the creator filter until
|
||||
// the org-wide user list resolves.
|
||||
const pageAuthorEmails = useMemo<string[]>(
|
||||
() =>
|
||||
rawDashboards
|
||||
.map((d) => d.createdBy)
|
||||
.filter((email): email is string => !!email),
|
||||
[rawDashboards],
|
||||
);
|
||||
const creatorOptions = useCreatorOptions({
|
||||
currentUserEmail: user.email,
|
||||
fallbackEmails: pageAuthorEmails,
|
||||
});
|
||||
// Creator filter options: distinct authors on the loaded page plus the
|
||||
// current user (so "me" is always selectable). Page-scoped until a members
|
||||
// source backs this.
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const emails = new Set<string>();
|
||||
if (user.email) {
|
||||
emails.add(user.email);
|
||||
}
|
||||
rawDashboards.forEach((d) => {
|
||||
if (d.createdBy) {
|
||||
emails.add(d.createdBy);
|
||||
}
|
||||
});
|
||||
return [...emails].sort().map((email) => ({
|
||||
email,
|
||||
label: email === user.email ? `${email} (me)` : email,
|
||||
}));
|
||||
}, [rawDashboards, user.email]);
|
||||
|
||||
// All key:value tags the API reports for the org's dashboards, powering the
|
||||
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
|
||||
@@ -283,8 +289,8 @@ function DashboardsList(): JSX.Element {
|
||||
onSave={saveView}
|
||||
onSaveChanges={saveActiveView}
|
||||
onReset={handleResetView}
|
||||
onClearFilters={handleClearAll}
|
||||
onDelete={handleRemoveView}
|
||||
onRename={renameView}
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.mainScroll}>
|
||||
@@ -299,7 +305,6 @@ function DashboardsList(): JSX.Element {
|
||||
<CommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
isModified={isModified}
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
|
||||
@@ -6,45 +6,32 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
export const VIEW_NAME_MAX_LENGTH = 128;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (name: string) => void;
|
||||
onSave: (name: string) => void;
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
confirmLabel: string;
|
||||
initialName?: string;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
// Name-input popover shared by "save as view" and "rename view"; enforces the
|
||||
// view-name length cap in one place.
|
||||
function ViewNamePopover({
|
||||
function SaveViewPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onSave,
|
||||
trigger,
|
||||
title,
|
||||
confirmLabel,
|
||||
initialName = '',
|
||||
testIdPrefix = 'view-name',
|
||||
}: Props): JSX.Element {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName);
|
||||
setName('');
|
||||
}
|
||||
}, [open, initialName]);
|
||||
}, [open]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave = trimmed.length > 0 && trimmed.length <= VIEW_NAME_MAX_LENGTH;
|
||||
const canSave = name.trim().length > 0;
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSubmit(trimmed);
|
||||
onSave(name);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -57,14 +44,13 @@ function ViewNamePopover({
|
||||
trigger={trigger}
|
||||
>
|
||||
<div className={styles.savePopover}>
|
||||
<div className={styles.saveTitle}>{title}</div>
|
||||
<div className={styles.saveTitle}>Save as view</div>
|
||||
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={VIEW_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Prod alerts"
|
||||
testId={`${testIdPrefix}-name`}
|
||||
testId="save-view-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
@@ -88,10 +74,10 @@ function ViewNamePopover({
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!canSave}
|
||||
testId={`${testIdPrefix}-confirm`}
|
||||
testId="save-view-confirm"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{confirmLabel}
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,4 +85,4 @@ function ViewNamePopover({
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewNamePopover;
|
||||
export default SaveViewPopover;
|
||||
@@ -145,40 +145,30 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// Ghost action icons overlaid on the row's right edge — absolutely positioned so
|
||||
// they never reserve layout space or affect row height. Hidden until row hover,
|
||||
// and inert while hidden so they can't intercept clicks meant for the row.
|
||||
.itemActions {
|
||||
.itemAction {
|
||||
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
|
||||
// so it never reserves layout space or affects the row height. Transparent so
|
||||
// the row's hover background shows through; turns red only on its own hover.
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
--button-height: 20px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 4px;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--l1-background);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.itemActionDanger {
|
||||
--button-variant-ghost-hover-background-color: var(--danger-background);
|
||||
--button-variant-ghost-hover-color: var(--danger-color, #fff);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0;
|
||||
// Hidden until row hover, and inert while hidden so it can't intercept clicks
|
||||
// meant for the row.
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.row:hover .itemActions {
|
||||
.row:hover .itemAction {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,12 @@ import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
Bookmark,
|
||||
CircleAlert,
|
||||
PenLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import ViewNamePopover from './ViewNamePopover';
|
||||
import SaveViewPopover from './SaveViewPopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
@@ -31,8 +24,8 @@ interface Props {
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onClearFilters: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
interface ViewRow {
|
||||
@@ -56,11 +49,10 @@ function ViewsRail({
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onClearFilters,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
@@ -130,44 +122,20 @@ function ViewsRail({
|
||||
)}
|
||||
</Button>
|
||||
{row.deletable && (
|
||||
<div className={styles.itemActions}>
|
||||
<ViewNamePopover
|
||||
open={renamingId === row.id}
|
||||
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
|
||||
onSubmit={(name): void => onRename(row.id, name)}
|
||||
title="Rename view"
|
||||
confirmLabel="Rename"
|
||||
initialName={row.label}
|
||||
testIdPrefix="rename-view"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.itemAction}
|
||||
aria-label="Rename view"
|
||||
title="Rename view"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<PenLine size={12} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={cx(styles.itemAction, styles.itemActionDanger)}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.itemAction}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -177,13 +145,10 @@ function ViewsRail({
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<ViewNamePopover
|
||||
<SaveViewPopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSubmit={onSave}
|
||||
title="Save as view"
|
||||
confirmLabel="Save view"
|
||||
testIdPrefix="save-view"
|
||||
onSave={onSave}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -303,8 +268,13 @@ function ViewsRail({
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
|
||||
Reset
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
combineQueries,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
} from '../utils/filterQuery';
|
||||
@@ -14,6 +15,7 @@ import { BuiltinViewId } from '../types';
|
||||
import type { DashboardFilterState, SavedView } from '../types';
|
||||
import {
|
||||
BUILTIN_VIEWS,
|
||||
builtinViewQuery,
|
||||
builtinViewSnapshot,
|
||||
type BuiltinView,
|
||||
isClientView,
|
||||
@@ -40,14 +42,15 @@ export interface UseActiveViewResult {
|
||||
isCustomActive: boolean;
|
||||
// Current filters diverge from the active view's canonical snapshot.
|
||||
isModified: boolean;
|
||||
// Whether the active view constrains the list client-side (pinned/recent).
|
||||
// Extra server-query fragment the active view contributes, and whether it
|
||||
// constrains the list client-side (pinned/recent).
|
||||
viewQuery: string;
|
||||
clientView: boolean;
|
||||
selectView: (id: string) => void;
|
||||
saveView: (name: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => void;
|
||||
renameView: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
// The canonical filter snapshot a saved view "is": the backend stores a flat
|
||||
@@ -124,9 +127,11 @@ export function useActiveView({
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string): void => {
|
||||
// The active view's clause already lives in the filter state (e.g. Locked
|
||||
// seeds `locked = true` into search), so the chips fold into one query.
|
||||
const query = filterStateToQuery(filters);
|
||||
// Fold the current built-in clause + chips into a single query string.
|
||||
const query = combineQueries(
|
||||
builtinViewQuery(activeViewId),
|
||||
filterStateToQuery(filters),
|
||||
);
|
||||
void (async (): Promise<void> => {
|
||||
const created = await createView({
|
||||
name,
|
||||
@@ -142,7 +147,15 @@ export function useActiveView({
|
||||
}
|
||||
})();
|
||||
},
|
||||
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
|
||||
[
|
||||
activeViewId,
|
||||
filters,
|
||||
createView,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
setActiveViewId,
|
||||
applyFilters,
|
||||
],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
@@ -187,23 +200,6 @@ export function useActiveView({
|
||||
[deleteView, activeViewId, setActiveViewId, applyFilters],
|
||||
);
|
||||
|
||||
// Rename only touches the view's name; its stored query/sort/order are preserved.
|
||||
const renameView = useCallback(
|
||||
(id: string, name: string): void => {
|
||||
const view = customViews.find((v) => v.id === id);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
updateView(id, {
|
||||
name,
|
||||
query: view.query,
|
||||
sort: view.sort,
|
||||
order: view.order,
|
||||
});
|
||||
},
|
||||
[customViews, updateView],
|
||||
);
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
@@ -211,12 +207,12 @@ export function useActiveView({
|
||||
customViewsLoading,
|
||||
isCustomActive: !!activeCustom,
|
||||
isModified,
|
||||
viewQuery: builtinViewQuery(activeViewId),
|
||||
clientView: isClientView(activeViewId),
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
renameView,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
|
||||
import type { CreatorOption } from '../components/FilterZone/FilterChips';
|
||||
|
||||
interface Args {
|
||||
currentUserEmail: string;
|
||||
// Authors on the loaded page — kept selectable until the org list resolves.
|
||||
fallbackEmails: string[];
|
||||
}
|
||||
|
||||
// Creator-filter options sourced from the org's full user list, so authors who
|
||||
// aren't on the current page are still selectable (v2 "List users" API).
|
||||
export function useCreatorOptions({
|
||||
currentUserEmail,
|
||||
fallbackEmails,
|
||||
}: Args): CreatorOption[] {
|
||||
const { data } = useListUsers();
|
||||
|
||||
return useMemo<CreatorOption[]>(() => {
|
||||
const users = data?.data ?? [];
|
||||
const emails = new Set<string>();
|
||||
if (currentUserEmail) {
|
||||
emails.add(currentUserEmail);
|
||||
}
|
||||
users.forEach((u) => u.email && emails.add(u.email));
|
||||
// Until the org list resolves (or if it comes back empty), keep the page's
|
||||
// authors selectable so the filter never regresses to just "me".
|
||||
if (users.length === 0) {
|
||||
fallbackEmails.forEach((e) => emails.add(e));
|
||||
}
|
||||
|
||||
const labelFor = (email: string): string => {
|
||||
if (email === currentUserEmail) {
|
||||
return `${email} (me)`;
|
||||
}
|
||||
const match = users.find((u) => u.email === email);
|
||||
return match?.displayName ? `${match.displayName} (${email})` : email;
|
||||
};
|
||||
|
||||
return [...emails].sort().map((email) => ({ email, label: labelFor(email) }));
|
||||
}, [data, currentUserEmail, fallbackEmails]);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getListDashboardViewsQueryKey,
|
||||
invalidateListDashboardViews,
|
||||
useCreateDashboardView,
|
||||
useDeleteDashboardView,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
type DashboardtypesDashboardViewDTO,
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
type ListDashboardViews200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SavedView, SavedViewInput } from '../types';
|
||||
@@ -72,27 +70,9 @@ export function useSavedViews(): UseSavedViewsResult {
|
||||
},
|
||||
},
|
||||
});
|
||||
// Rename/save-changes returns the updated view, so patch it into the cached
|
||||
// list inline instead of refetching the whole list.
|
||||
const updateMutation = useUpdateDashboardView({
|
||||
mutation: {
|
||||
onSuccess: (response): void => {
|
||||
const updated = response?.data;
|
||||
const key = getListDashboardViewsQueryKey();
|
||||
const prev = queryClient.getQueryData<ListDashboardViews200>(key);
|
||||
if (!updated || !prev) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<ListDashboardViews200>(key, {
|
||||
...prev,
|
||||
data: {
|
||||
...prev.data,
|
||||
views: (prev.data.views ?? []).map((v) =>
|
||||
v.id === updated.id ? updated : v,
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError: (): void => {
|
||||
toast.error('Failed to update view.');
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Built-in view catalogue + the pure logic that maps a view to how it
|
||||
// constrains the list. Views fall into two mechanisms:
|
||||
// - snapshot: selecting applies a filter snapshot — some seed the search box
|
||||
// with DSL (My dashboards → created_by; Locked → locked = true)
|
||||
// constrains the list. Views fall into three mechanisms:
|
||||
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
|
||||
// - query: contributes an extra server clause AND-ed with the chips (Locked)
|
||||
// - client: constrains by a client-side id set (Favorites, Recently viewed)
|
||||
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
|
||||
|
||||
@@ -49,9 +49,9 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
export const isClientView = (id: string): boolean =>
|
||||
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
|
||||
|
||||
// DSL the Locked view seeds into the search box, so the constraint is visible
|
||||
// (and editable) rather than applied invisibly behind the scenes.
|
||||
export const LOCKED_QUERY = 'locked = true';
|
||||
// Extra server query fragment a built-in view contributes (AND-ed with chips).
|
||||
export const builtinViewQuery = (id: string): string =>
|
||||
id === BuiltinViewId.Locked ? 'locked = true' : '';
|
||||
|
||||
// The canonical filter snapshot a built-in view applies when selected. `null`
|
||||
// for ids that aren't built-in (custom views carry their own snapshot).
|
||||
@@ -65,11 +65,10 @@ export const builtinViewSnapshot = (
|
||||
...DEFAULT_FILTER_STATE,
|
||||
createdBy: userEmail ? [userEmail] : [],
|
||||
};
|
||||
case BuiltinViewId.Locked:
|
||||
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
|
||||
case BuiltinViewId.All:
|
||||
case BuiltinViewId.Pinned:
|
||||
case BuiltinViewId.Recent:
|
||||
case BuiltinViewId.Locked:
|
||||
return { ...DEFAULT_FILTER_STATE };
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -33,19 +33,15 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.log-quick-filter-left-section {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
@@ -54,9 +50,7 @@
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { defaultTo, isEmpty, isNull } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { EventSourceProvider } from 'providers/EventSource';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -46,23 +44,9 @@ import { ExplorerViews } from './utils';
|
||||
|
||||
import './LogsExplorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function LogsExplorer(): JSX.Element {
|
||||
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
@@ -242,25 +226,14 @@ function LogsExplorer(): JSX.Element {
|
||||
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
|
||||
>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="log-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className={cx('log-quick-filter-left-section')}>
|
||||
<QuickFilters
|
||||
className="qf-logs-explorer"
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
)}
|
||||
<section className={cx('log-module-right-section')}>
|
||||
<Toolbar
|
||||
|
||||
@@ -18,17 +18,6 @@
|
||||
z-index: 10;
|
||||
background: var(--l2-border);
|
||||
|
||||
// Extend the interactive area beyond the 1px visual line so the handle
|
||||
// is easy to grab and double-click, without changing its appearance.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
@@ -66,29 +55,4 @@
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Visible grip indicator (opt-in via the `withHandle` prop). Purely visual —
|
||||
// pointer events fall through to the handle so it still owns drag + reset.
|
||||
&__grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__handle:hover &__grip,
|
||||
&__handle:active &__grip {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
@@ -12,28 +11,15 @@ export interface ResizableBoxProps {
|
||||
// resize (width). Dragging the handle away from the content grows the box;
|
||||
// dragging it toward the content shrinks it.
|
||||
handle?: ResizableBoxHandle;
|
||||
// Canonical default size, and the target that double-click reset restores to.
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
// Starting size when different from the default (e.g. a persisted value).
|
||||
// Falls back to defaultWidth/defaultHeight when omitted, preserving the
|
||||
// behavior of callers that don't opt in.
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
// When true, double-clicking the handle resets the size to
|
||||
// defaultWidth/defaultHeight and fires onResize with that value.
|
||||
resetToDefaultOnDoubleClick?: boolean;
|
||||
// When true, renders a visible grip indicator on the handle so it is
|
||||
// discoverable as a draggable affordance.
|
||||
withHandle?: boolean;
|
||||
onResize?: (size: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
handleTestId?: string;
|
||||
}
|
||||
|
||||
function ResizableBox({
|
||||
@@ -45,22 +31,13 @@ function ResizableBox({
|
||||
defaultWidth = 200,
|
||||
minWidth = 50,
|
||||
maxWidth = Infinity,
|
||||
initialWidth,
|
||||
initialHeight,
|
||||
resetToDefaultOnDoubleClick = false,
|
||||
withHandle = false,
|
||||
onResize,
|
||||
disabled = false,
|
||||
className,
|
||||
handleTestId,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = handle === 'left' || handle === 'right';
|
||||
const isStartHandle = handle === 'top' || handle === 'left';
|
||||
const [size, setSize] = useState(
|
||||
isHorizontal
|
||||
? (initialWidth ?? defaultWidth)
|
||||
: (initialHeight ?? defaultHeight),
|
||||
);
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -106,21 +83,6 @@ function ResizableBox({
|
||||
],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback((): void => {
|
||||
if (!resetToDefaultOnDoubleClick) {
|
||||
return;
|
||||
}
|
||||
const nextSize = isHorizontal ? defaultWidth : defaultHeight;
|
||||
setSize(nextSize);
|
||||
onResize?.(nextSize);
|
||||
}, [
|
||||
resetToDefaultOnDoubleClick,
|
||||
isHorizontal,
|
||||
defaultWidth,
|
||||
defaultHeight,
|
||||
onResize,
|
||||
]);
|
||||
|
||||
const containerStyle = disabled
|
||||
? undefined
|
||||
: isHorizontal
|
||||
@@ -137,22 +99,7 @@ function ResizableBox({
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="resizable-box__content">{children}</div>
|
||||
{!disabled && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
||||
className={handleClass}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
data-testid={handleTestId}
|
||||
>
|
||||
{withHandle && (
|
||||
<span className="resizable-box__grip">
|
||||
<GripVertical size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import ResizableBox from '../ResizableBox';
|
||||
|
||||
const HANDLE_TEST_ID = 'resize-handle';
|
||||
|
||||
describe('ResizableBox', () => {
|
||||
it('starts at defaultWidth when initialWidth is omitted', () => {
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
|
||||
expect(box.style.width).toBe('260px');
|
||||
});
|
||||
|
||||
it('starts at initialWidth when provided', () => {
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
initialWidth={340}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
|
||||
expect(box.style.width).toBe('340px');
|
||||
});
|
||||
|
||||
it('resets to defaultWidth and fires onResize on double-click when enabled', () => {
|
||||
const onResize = jest.fn();
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
initialWidth={480}
|
||||
onResize={onResize}
|
||||
resetToDefaultOnDoubleClick
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const handle = screen.getByTestId(HANDLE_TEST_ID);
|
||||
const box = handle.parentElement as HTMLElement;
|
||||
expect(box.style.width).toBe('480px');
|
||||
|
||||
fireEvent.doubleClick(handle);
|
||||
|
||||
expect(box.style.width).toBe('260px');
|
||||
expect(onResize).toHaveBeenCalledWith(260);
|
||||
});
|
||||
|
||||
it('does nothing on double-click when reset is not enabled', () => {
|
||||
const onResize = jest.fn();
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
initialWidth={480}
|
||||
onResize={onResize}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const handle = screen.getByTestId(HANDLE_TEST_ID);
|
||||
const box = handle.parentElement as HTMLElement;
|
||||
|
||||
fireEvent.doubleClick(handle);
|
||||
|
||||
expect(box.style.width).toBe('480px');
|
||||
expect(onResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a visible grip only when withHandle is set', () => {
|
||||
const { rerender, container } = render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
expect(container.querySelector('.resizable-box__grip')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
withHandle
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
expect(container.querySelector('.resizable-box__grip')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clamps drag to maxWidth and reports the clamped size via onResize', () => {
|
||||
const onResize = jest.fn();
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
minWidth={240}
|
||||
maxWidth={500}
|
||||
onResize={onResize}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const handle = screen.getByTestId(HANDLE_TEST_ID);
|
||||
const box = handle.parentElement as HTMLElement;
|
||||
|
||||
fireEvent.mouseDown(handle, { clientX: 0 });
|
||||
fireEvent.mouseMove(document, { clientX: 1000 });
|
||||
fireEvent.mouseUp(document);
|
||||
|
||||
expect(box.style.width).toBe('500px');
|
||||
expect(onResize).toHaveBeenLastCalledWith(500);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import usePanelWidth from '../usePanelWidth';
|
||||
|
||||
jest.mock('api/browser/localstorage/get');
|
||||
jest.mock('api/browser/localstorage/set');
|
||||
|
||||
const mockedGet = getLocalStorageKey as jest.MockedFunction<
|
||||
typeof getLocalStorageKey
|
||||
>;
|
||||
const mockedSet = setLocalStorageKey as jest.MockedFunction<
|
||||
typeof setLocalStorageKey
|
||||
>;
|
||||
|
||||
const ARGS = {
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
defaultWidth: 260,
|
||||
minWidth: 240,
|
||||
maxWidth: 500,
|
||||
};
|
||||
|
||||
describe('usePanelWidth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns defaultWidth when nothing is persisted', () => {
|
||||
mockedGet.mockReturnValue(null);
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(260);
|
||||
});
|
||||
|
||||
it('returns the persisted width when present', () => {
|
||||
mockedGet.mockReturnValue('340');
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(340);
|
||||
});
|
||||
|
||||
it('clamps an out-of-bounds persisted width on read', () => {
|
||||
mockedGet.mockReturnValue('9999');
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(500);
|
||||
});
|
||||
|
||||
it('falls back to defaultWidth for an invalid persisted value', () => {
|
||||
mockedGet.mockReturnValue('not-a-number');
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(260);
|
||||
});
|
||||
|
||||
it('persists a clamped width (debounced)', () => {
|
||||
mockedGet.mockReturnValue(null);
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
|
||||
act(() => {
|
||||
result.current.persistWidth(320);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(mockedSet).toHaveBeenCalledWith(
|
||||
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
'320',
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps below-min widths before persisting', () => {
|
||||
mockedGet.mockReturnValue(null);
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
|
||||
act(() => {
|
||||
result.current.persistWidth(10);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(mockedSet).toHaveBeenCalledWith(
|
||||
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
'240',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 150;
|
||||
|
||||
interface UsePanelWidthArgs {
|
||||
/** Per-page localStorage key the width is persisted under. */
|
||||
storageKey: LOCALSTORAGE;
|
||||
/** Canonical default width, used when nothing is persisted. */
|
||||
defaultWidth: number;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
interface UsePanelWidthReturn {
|
||||
/** Width to start from: the persisted value (clamped) or the default. */
|
||||
initialWidth: number;
|
||||
/** Clamp and persist a width. Debounced to avoid a write per mousemove. */
|
||||
persistWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* Per-page localStorage persistence for a resizable panel width. Mirrors the
|
||||
* getLocalStorageKey/setLocalStorageKey idiom used for the trace span-details
|
||||
* panel position. Pairs with ResizableBox: feed initialWidth into its
|
||||
* initialWidth prop and persistWidth into its onResize.
|
||||
*/
|
||||
function usePanelWidth({
|
||||
storageKey,
|
||||
defaultWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
}: UsePanelWidthArgs): UsePanelWidthReturn {
|
||||
// Read once on mount. Kept in a ref so a re-render doesn't re-read storage.
|
||||
const initialWidthRef = useRef<number | null>(null);
|
||||
if (initialWidthRef.current === null) {
|
||||
const stored = getLocalStorageKey(storageKey);
|
||||
const parsed = stored !== null && stored !== '' ? Number(stored) : NaN;
|
||||
initialWidthRef.current = Number.isFinite(parsed)
|
||||
? clamp(parsed, minWidth, maxWidth)
|
||||
: defaultWidth;
|
||||
}
|
||||
|
||||
const debouncedWrite = useMemo(
|
||||
() =>
|
||||
debounce((width: number): void => {
|
||||
setLocalStorageKey(storageKey, String(width));
|
||||
}, PERSIST_DEBOUNCE_MS),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const persistWidth = useCallback(
|
||||
(width: number): void => {
|
||||
debouncedWrite(clamp(width, minWidth, maxWidth));
|
||||
},
|
||||
[debouncedWrite, minWidth, maxWidth],
|
||||
);
|
||||
|
||||
return { initialWidth: initialWidthRef.current, persistWidth };
|
||||
}
|
||||
|
||||
export default usePanelWidth;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { linkifyText } from '../linkifyText';
|
||||
|
||||
describe('linkifyText', () => {
|
||||
it('returns plain text unchanged when there are no links', () => {
|
||||
render(<div>{linkifyText('just a plain description')}</div>);
|
||||
|
||||
expect(screen.getByText('just a plain description')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wraps an http(s) URL in an anchor that opens in a new tab', () => {
|
||||
render(<div>{linkifyText('see https://signoz.io/docs for more')}</div>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://signoz.io/docs' });
|
||||
expect(link).toHaveAttribute('href', 'https://signoz.io/docs');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('prefixes bare www. links with https://', () => {
|
||||
render(<div>{linkifyText('visit www.signoz.io')}</div>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'www.signoz.io' });
|
||||
expect(link).toHaveAttribute('href', 'https://www.signoz.io');
|
||||
});
|
||||
|
||||
it('keeps trailing punctuation outside the link', () => {
|
||||
render(<div>{linkifyText('read https://signoz.io.')}</div>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://signoz.io' });
|
||||
expect(link).toHaveAttribute('href', 'https://signoz.io');
|
||||
});
|
||||
|
||||
it('linkifies multiple URLs in the same string', () => {
|
||||
render(
|
||||
<div>{linkifyText('a https://one.com and b https://two.com end')}</div>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://one.com' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://two.com' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves newlines around a link', () => {
|
||||
const { container } = render(
|
||||
<div>{linkifyText('line one\nsee https://signoz.io\nline three')}</div>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe(
|
||||
'line one\nsee https://signoz.io\nline three',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://signoz.io' }),
|
||||
).toHaveAttribute('href', 'https://signoz.io');
|
||||
});
|
||||
|
||||
it('returns an empty string unchanged', () => {
|
||||
expect(linkifyText('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Fragment, MouseEvent, ReactNode } from 'react';
|
||||
|
||||
/** Matches http(s) URLs and bare www. links up to the next whitespace. */
|
||||
const URL_REGEX = /((?:https?:\/\/|www\.)[^\s]+)/gi;
|
||||
/** Trailing punctuation that is almost never part of the intended URL. */
|
||||
const TRAILING_PUNCTUATION = /[.,;:!?)\]}'"]+$/;
|
||||
|
||||
const stopPropagation = (
|
||||
event: MouseEvent<HTMLAnchorElement, globalThis.MouseEvent>,
|
||||
): void => {
|
||||
// Prevent parent click listeners (e.g. title edit) from firing.
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits `text` into plain-text and anchor segments, wrapping any detected
|
||||
* URL in an anchor that opens in a new tab. Trailing punctuation is kept
|
||||
* outside the link so sentences like "see https://signoz.io." stay clean.
|
||||
*/
|
||||
export function linkifyText(text: string): ReactNode {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const segments: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let key = 0;
|
||||
|
||||
const matches = text.matchAll(URL_REGEX);
|
||||
for (const match of matches) {
|
||||
const matchStart = match.index ?? 0;
|
||||
const rawUrl = match[0];
|
||||
|
||||
const trailing = rawUrl.match(TRAILING_PUNCTUATION)?.[0] ?? '';
|
||||
const url = trailing ? rawUrl.slice(0, -trailing.length) : rawUrl;
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
segments.push(
|
||||
<Fragment key={key}>{text.slice(lastIndex, matchStart)}</Fragment>,
|
||||
);
|
||||
key += 1;
|
||||
}
|
||||
|
||||
segments.push(
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{url}
|
||||
</a>,
|
||||
);
|
||||
key += 1;
|
||||
|
||||
if (trailing) {
|
||||
segments.push(<Fragment key={key}>{trailing}</Fragment>);
|
||||
key += 1;
|
||||
}
|
||||
|
||||
lastIndex = matchStart + rawUrl.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push(<Fragment key={key}>{text.slice(lastIndex)}</Fragment>);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-font-properties
|
||||
*
|
||||
* Bans direct font/text CSS properties with hardcoded values.
|
||||
* Use Typography component from @signozhq/ui or design tokens.
|
||||
*
|
||||
* BAD:
|
||||
* font-family: Arial;
|
||||
* font-weight: bold;
|
||||
* font-size: 14px;
|
||||
* line-height: 1.5;
|
||||
* letter-spacing: 1px;
|
||||
*
|
||||
* GOOD (prefer Typography component):
|
||||
* <Typography size="base" weight="medium">Text</Typography>
|
||||
*
|
||||
* GOOD (CSS tokens when Typography not applicable):
|
||||
* font-size: var(--font-size-base);
|
||||
* font-weight: var(--font-weight-medium);
|
||||
* line-height: var(--line-height-normal);
|
||||
* letter-spacing: var(--letter-spacing-normal);
|
||||
*
|
||||
* Available tokens from @signozhq/design-tokens:
|
||||
* --font-size-{xs,sm,base,lg,xl,2xl...9xl}
|
||||
* --font-weight-{thin,extralight,light,normal,medium,semibold,bold,extrabold,black}
|
||||
* --line-height-{none,tight,snug,normal,relaxed,loose}
|
||||
* --letter-spacing-{tighter,tight,normal,wide,wider}
|
||||
* --periscope-font-size-{small,base,medium,large}
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-font-properties';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
banned: (property) =>
|
||||
`Direct '${property}' with hardcoded value is banned. Use Typography component or design tokens (--font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*).`,
|
||||
});
|
||||
|
||||
const BANNED_PROPERTIES = new Set([
|
||||
'font-family',
|
||||
'font-weight',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font',
|
||||
'text-transform',
|
||||
'text-decoration',
|
||||
'letter-spacing',
|
||||
'line-height',
|
||||
'word-spacing',
|
||||
'text-indent',
|
||||
]);
|
||||
|
||||
// Allow if value uses CSS variable tokens
|
||||
function usesDesignToken(value) {
|
||||
return /var\s*\(\s*--/.test(value);
|
||||
}
|
||||
|
||||
// Allow inherit/initial/unset
|
||||
function isAllowedValue(value) {
|
||||
const lower = value.toLowerCase().trim();
|
||||
return ['inherit', 'initial', 'unset', 'normal', 'none'].includes(lower);
|
||||
}
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop.toLowerCase();
|
||||
|
||||
if (!BANNED_PROPERTIES.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow if using design tokens or allowed values
|
||||
if (usesDesignToken(decl.value) || isAllowedValue(decl.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stylelint.utils.report({
|
||||
message: messages.banned(decl.prop),
|
||||
node: decl,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-primitive-color-tokens
|
||||
*
|
||||
* Bans primitive color tokens from @signozhq/design-tokens.
|
||||
* Use semantic tokens instead.
|
||||
*
|
||||
* PRIMITIVE (banned):
|
||||
* --bg-robin-500, --bg-cherry-200, --bg-slate-400
|
||||
* --text-robin-500, --text-vanilla-100
|
||||
* --bg-gradient-coral, --text-gradient-dawn
|
||||
* --bg-base-black, --text-base-white
|
||||
*
|
||||
* SEMANTIC (allowed):
|
||||
* --l1-background, --l2-foreground, --l3-border
|
||||
* --primary-*, --secondary-*, --accent-*
|
||||
* --danger-*, --warning-*, --success-*
|
||||
* --callout-*, --chart-*, --sidebar-*
|
||||
*
|
||||
* See docs/css-modules-guide.md for semantic token reference.
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-primitive-color-tokens';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
primitiveToken: (token) =>
|
||||
`Primitive color token '${token}' is banned. Use semantic tokens (--l1-*, --l2-*, --l3-*, --accent-*, --primary-*, etc). See docs/css-modules-guide.md.`,
|
||||
});
|
||||
|
||||
// Primitive color names from @signozhq/design-tokens
|
||||
const PRIMITIVE_COLORS = [
|
||||
'robin',
|
||||
'sienna',
|
||||
'cherry',
|
||||
'aqua',
|
||||
'sakura',
|
||||
'amber',
|
||||
'forest',
|
||||
'ink',
|
||||
'vanilla',
|
||||
'slate',
|
||||
'neutraldark',
|
||||
'neutrallight',
|
||||
'base',
|
||||
'gradient',
|
||||
];
|
||||
|
||||
// Matches: --bg-<color>-<number|name> or --text-<color>-<number|name>
|
||||
// Examples: --bg-robin-500, --text-cherry-400, --bg-base-black, --bg-gradient-coral
|
||||
const PRIMITIVE_TOKEN_PATTERN = new RegExp(
|
||||
`--(bg|text)-(${PRIMITIVE_COLORS.join('|')})-[a-z0-9-]+`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const value = decl.value;
|
||||
|
||||
// Reset lastIndex for global regex
|
||||
PRIMITIVE_TOKEN_PATTERN.lastIndex = 0;
|
||||
|
||||
const matches = value.match(PRIMITIVE_TOKEN_PATTERN);
|
||||
if (matches) {
|
||||
for (const token of matches) {
|
||||
stylelint.utils.report({
|
||||
message: messages.primitiveToken(token),
|
||||
node: decl,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-styles-scss
|
||||
*
|
||||
* Bans files named styles.scss (non-module).
|
||||
* Use .module.scss for CSS modules instead.
|
||||
*
|
||||
* BAD:
|
||||
* styles.scss
|
||||
* Component.styles.scss
|
||||
*
|
||||
* GOOD:
|
||||
* Component.module.scss
|
||||
* styles.module.scss
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
import path from 'path';
|
||||
|
||||
const ruleName = 'local/no-styles-scss';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
banned: (filename) =>
|
||||
`Non-module SCSS file '${filename}' is banned. Use .module.scss for CSS modules instead.`,
|
||||
});
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = result.opts?.from;
|
||||
if (!filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
const basename = path.basename(filename);
|
||||
|
||||
// Check if it's a non-module styles.scss file
|
||||
if (
|
||||
basename.endsWith('.scss') &&
|
||||
!basename.endsWith('.module.scss') &&
|
||||
(basename === 'styles.scss' || basename.includes('.styles.scss'))
|
||||
) {
|
||||
stylelint.utils.report({
|
||||
message: messages.banned(basename),
|
||||
node: root,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
@@ -214,37 +213,23 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compute per-group active/inactive host counts.
|
||||
// When host.name is in groupBy, each row = one host, so counts are derived
|
||||
// directly from activeHostsMap in buildHostRecords (no extra query needed).
|
||||
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
|
||||
// using the same filter expression as the main query (including user filters + page groups IN clause).
|
||||
hostCounts := make(map[string]groupHostStatusCounts)
|
||||
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
hostCounts = make(map[string]groupHostStatusCounts)
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
|
||||
if !isHostNameInGroupBy {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(gCtx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
|
||||
@@ -314,39 +299,23 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
restartCounts map[string]int64
|
||||
)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
statusCounts, statusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
restartCounts, err := m.getPerGroupPodRestartCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -418,39 +387,23 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCounts map[string]nodeConditionCounts
|
||||
podPhaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -522,33 +475,18 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -619,41 +557,25 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
|
||||
// with a custom groupBy, they aggregate across clusters in that group.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCountsMap map[string]nodeConditionCounts
|
||||
podPhaseCountsMap map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -808,33 +730,18 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -911,35 +818,20 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
|
||||
// so default-groupBy gives per-statefulset phase counts automatically.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1016,35 +908,20 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
|
||||
// gives per-job phase counts automatically.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1121,35 +998,20 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
|
||||
// so default-groupBy gives per-daemonset phase counts automatically.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
const DefaultMaxConcurrentQueries = 8
|
||||
|
||||
type SkipResourceFingerprint struct {
|
||||
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
|
||||
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
|
||||
@@ -39,7 +37,7 @@ func newConfig() factory.Config {
|
||||
// Default values
|
||||
CacheTTL: 168 * time.Hour,
|
||||
FluxInterval: 5 * time.Minute,
|
||||
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
|
||||
MaxConcurrentQueries: 4,
|
||||
SkipResourceFingerprint: SkipResourceFingerprint{
|
||||
Enabled: false,
|
||||
Threshold: 100000,
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -36,21 +35,20 @@ var (
|
||||
)
|
||||
|
||||
type querier struct {
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
bucketCache BucketCache
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
maxConcurrentQueries int
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
bucketCache BucketCache
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
}
|
||||
|
||||
var _ Querier = (*querier)(nil)
|
||||
@@ -69,30 +67,25 @@ func New(
|
||||
bucketCache BucketCache,
|
||||
flagger flagger.Flagger,
|
||||
logTraceIDWindowPadding time.Duration,
|
||||
maxConcurrentQueries int,
|
||||
) *querier {
|
||||
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
|
||||
if maxConcurrentQueries <= 0 {
|
||||
maxConcurrentQueries = DefaultMaxConcurrentQueries
|
||||
}
|
||||
return &querier{
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
meterStmtBuilder: meterStmtBuilder,
|
||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||
bucketCache: bucketCache,
|
||||
liveDataRefresh: 5 * time.Second,
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
meterStmtBuilder: meterStmtBuilder,
|
||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||
bucketCache: bucketCache,
|
||||
liveDataRefresh: 5 * time.Second,
|
||||
builderConfig: builderConfig{
|
||||
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
|
||||
},
|
||||
maxConcurrentQueries: maxConcurrentQueries,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,40 +607,30 @@ func (q *querier) run(
|
||||
return false
|
||||
}
|
||||
|
||||
names := maps.Keys(qs)
|
||||
slices.Sort(names)
|
||||
queryResults := make([]*qbtypes.Result, len(names))
|
||||
|
||||
// sem limits how many queries run at once for this request. The same
|
||||
// limit covers the missing-range queries in executeWithCache. sem is held
|
||||
// only while a query is running, never while waiting for other
|
||||
// goroutines, so the two levels cannot deadlock.
|
||||
sem := make(chan struct{}, q.maxConcurrentQueries)
|
||||
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for i, name := range names {
|
||||
query := qs[name]
|
||||
eg.Go(func() error {
|
||||
// Skip cache if NoCache is set, or if cache is not available
|
||||
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
|
||||
if req.NoCache {
|
||||
q.logger.DebugContext(egCtx, "NoCache flag set, bypassing cache", slog.String("query", name))
|
||||
} else {
|
||||
q.logger.InfoContext(egCtx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
|
||||
}
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(egCtx)
|
||||
<-sem
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queryResults[i] = result
|
||||
return nil
|
||||
for name, query := range qs {
|
||||
// Skip cache if NoCache is set, or if cache is not available
|
||||
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
|
||||
if req.NoCache {
|
||||
q.logger.DebugContext(ctx, "NoCache flag set, bypassing cache", slog.String("query", name))
|
||||
} else {
|
||||
q.logger.InfoContext(ctx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
|
||||
}
|
||||
|
||||
result, err := q.executeWithCache(egCtx, orgID, query, steps[name], sem)
|
||||
result, err := query.Execute(ctx)
|
||||
qbEvent.HasData = qbEvent.HasData || hasData(result)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
} else {
|
||||
result, err := q.executeWithCache(ctx, orgID, query, steps[name], req.NoCache)
|
||||
qbEvent.HasData = qbEvent.HasData || hasData(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch v := result.Value.(type) {
|
||||
case *qbtypes.TimeSeriesData:
|
||||
@@ -657,23 +640,14 @@ func (q *querier) run(
|
||||
case *qbtypes.RawData:
|
||||
v.QueryName = name
|
||||
}
|
||||
queryResults[i] = result
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
result := queryResults[i]
|
||||
qbEvent.HasData = qbEvent.HasData || hasData(result)
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
}
|
||||
}
|
||||
|
||||
gomaps.Copy(results, preseededResults)
|
||||
@@ -733,9 +707,8 @@ func (q *querier) run(
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// executeWithCache executes a query using the bucket cache. sem limits how
|
||||
// many queries run at once for the whole request.
|
||||
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, sem chan struct{}) (*qbtypes.Result, error) {
|
||||
// executeWithCache executes a query using the bucket cache.
|
||||
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, _ bool) (*qbtypes.Result, error) {
|
||||
// Get cached data and missing ranges
|
||||
cachedResult, missingRanges := q.bucketCache.GetMissRanges(ctx, orgID, query, step)
|
||||
|
||||
@@ -748,9 +721,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
if cachedResult == nil && len(missingRanges) == 1 {
|
||||
startMs, endMs := query.Window()
|
||||
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(ctx)
|
||||
<-sem
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -769,6 +740,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
slog.Int("missing_ranges_count", len(missingRanges)),
|
||||
slog.Any("ranges", missingRanges))
|
||||
|
||||
sem := make(chan struct{}, 4)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, timeRange := range missingRanges {
|
||||
@@ -805,9 +777,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
if err != nil {
|
||||
// If any query failed, fall back to full execution
|
||||
q.logger.ErrorContext(ctx, "parallel query execution failed", errors.Attr(err))
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(ctx)
|
||||
<-sem
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
@@ -56,8 +54,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
0, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries
|
||||
0,
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -128,8 +125,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
0, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries
|
||||
0,
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -159,149 +155,3 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
}
|
||||
|
||||
type fakeQuery struct {
|
||||
execute func(ctx context.Context) (*qbtypes.Result, error)
|
||||
}
|
||||
|
||||
func (f *fakeQuery) Fingerprint() string { return "" }
|
||||
func (f *fakeQuery) Window() (uint64, uint64) { return 0, 0 }
|
||||
func (f *fakeQuery) Execute(ctx context.Context) (*qbtypes.Result, error) { return f.execute(ctx) }
|
||||
|
||||
func chQueryEnvelopes(names []string) []qbtypes.QueryEnvelope {
|
||||
envelopes := make([]qbtypes.QueryEnvelope, 0, len(names))
|
||||
for _, name := range names {
|
||||
envelopes = append(envelopes, qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeClickHouseSQL,
|
||||
Spec: qbtypes.ClickHouseQuery{Name: name},
|
||||
})
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
func TestRunExecutesQueriesConcurrently(t *testing.T) {
|
||||
names := []string{"A", "B", "C", "D", "E"}
|
||||
numQueries := len(names)
|
||||
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
maxConcurrentQueries: numQueries,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var started atomic.Int32
|
||||
allStarted := make(chan struct{})
|
||||
|
||||
qs := make(map[string]qbtypes.Query, numQueries)
|
||||
for _, name := range names {
|
||||
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
if int(started.Add(1)) == numQueries {
|
||||
close(allStarted)
|
||||
}
|
||||
select {
|
||||
case <-allStarted:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeScalar,
|
||||
Value: &qbtypes.ScalarData{QueryName: name},
|
||||
Stats: qbtypes.ExecStats{RowsScanned: 1, BytesScanned: 2, DurationMS: 3},
|
||||
}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
|
||||
}
|
||||
resp, err := q.run(ctx, valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Len(t, resp.Data.Results, numQueries)
|
||||
assert.Equal(t, uint64(numQueries), resp.Meta.RowsScanned)
|
||||
assert.Equal(t, uint64(2*numQueries), resp.Meta.BytesScanned)
|
||||
assert.Equal(t, uint64(3*numQueries), resp.Meta.DurationMS)
|
||||
}
|
||||
|
||||
func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
|
||||
const limit = 2
|
||||
names := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
|
||||
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
maxConcurrentQueries: limit,
|
||||
}
|
||||
|
||||
var running, maxRunning atomic.Int32
|
||||
qs := make(map[string]qbtypes.Query, len(names))
|
||||
for _, name := range names {
|
||||
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
cur := running.Add(1)
|
||||
defer running.Add(-1)
|
||||
for {
|
||||
m := maxRunning.Load()
|
||||
if cur <= m || maxRunning.CompareAndSwap(m, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeScalar,
|
||||
Value: &qbtypes.ScalarData{QueryName: name},
|
||||
}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
|
||||
}
|
||||
resp, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Len(t, resp.Data.Results, len(names))
|
||||
assert.LessOrEqual(t, maxRunning.Load(), int32(limit), "running queries must not exceed maxConcurrentQueries")
|
||||
}
|
||||
|
||||
func TestRunQueryErrorCancelsSiblings(t *testing.T) {
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
maxConcurrentQueries: 4,
|
||||
}
|
||||
|
||||
bStarted := make(chan struct{})
|
||||
var bCanceled atomic.Bool
|
||||
|
||||
qs := map[string]qbtypes.Query{
|
||||
// fails once B is running.
|
||||
"A": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
select {
|
||||
case <-bStarted:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "query A failed")
|
||||
}},
|
||||
// blocks until its context is canceled by A's failure.
|
||||
"B": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
close(bStarted)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
bCanceled.Store(true)
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(10 * time.Second):
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "query B was never canceled")
|
||||
}
|
||||
}},
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes([]string{"A", "B"})},
|
||||
}
|
||||
_, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
|
||||
require.ErrorContains(t, err, "query A failed")
|
||||
assert.True(t, bCanceled.Load(), "query B should be canceled once query A fails")
|
||||
}
|
||||
|
||||
@@ -193,6 +193,5 @@ func newProvider(
|
||||
bucketCache,
|
||||
flagger,
|
||||
cfg.LogTraceIDWindowPadding,
|
||||
cfg.MaxConcurrentQueries,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // bucketCache
|
||||
flagger,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
@@ -111,7 +110,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
5*time.Minute, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,6 +158,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type migrateCommon struct {
|
||||
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
|
||||
// transform is stateless and shared with the v1→v2 dashboard conversion.
|
||||
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
|
||||
}
|
||||
|
||||
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {
|
||||
|
||||
353
pkg/transition/migrate_shape_safe.go
Normal file
353
pkg/transition/migrate_shape_safe.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// nolint
|
||||
package transition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Shape-safe (idempotent) migration
|
||||
// ══════════════════════════════════════════════
|
||||
//
|
||||
// A copy of the Migrate → updateWidget → updateQueryData chain with the
|
||||
// "uniformly v4 input" assumption removed, so it is safe on a dashboard whose
|
||||
// `version` tag lies (a "v5"-labelled dashboard with un-upgraded, possibly mixed,
|
||||
// bodies — the v1→v2 converter's case). Versus the original: no version gate, and
|
||||
// each step acts only on the pre-v5 shape (leaving a v5 field alone), so it is
|
||||
// idempotent. The original Migrate is left unchanged (battle-tested, no test net).
|
||||
// The *ShapeSafe methods below each note the original they copy; the reused steps
|
||||
// (createFilterExpression, fixGroupBy, buildAggregationExpression, orderByExpr) are
|
||||
// already v5-safe.
|
||||
|
||||
// MigrateQueryDataShapeSafe is the per-query entry point (the core of
|
||||
// updateQueryDataShapeSafe) for callers that process queries one at a time (the
|
||||
// v1→v2 converter). widgetType is the v1 panelTypes (metric reduceTo on tables);
|
||||
// "" is safe.
|
||||
func (m *dashboardMigrateV5) MigrateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
return m.updateQueryDataShapeSafe(ctx, queryData, widgetType)
|
||||
}
|
||||
|
||||
// updateQueryDataShapeSafe copies updateQueryData, with each destructive step
|
||||
// guarded to act only on the pre-v5 shape (see the file header).
|
||||
func (mc *migrateCommon) updateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
updated := false
|
||||
|
||||
aggregateOp, _ := queryData["aggregateOperator"].(string)
|
||||
hasAggregation := aggregateOp != "" && aggregateOp != "noop"
|
||||
|
||||
if mc.createAggregationsShapeSafe(ctx, queryData, widgetType) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// createFilterExpression only touches v4 `filters`; skip if a v5 `filter` exists.
|
||||
if _, hasFilter := queryData["filter"]; !hasFilter {
|
||||
if mc.createFilterExpression(ctx, queryData) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if mc.fixGroupBy(queryData) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if mc.createHavingExpressionShapeSafe(queryData) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if hasAggregation {
|
||||
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
|
||||
newOrderBy := make([]any, 0)
|
||||
for _, order := range orderBy {
|
||||
if orderMap, ok := order.(map[string]any); ok {
|
||||
columnName, _ := orderMap["columnName"].(string)
|
||||
// skip timestamp, id (logs, traces), samples(metrics) ordering for aggregation queries
|
||||
if columnName != "timestamp" && columnName != "samples" && columnName != "id" {
|
||||
if columnName == "#SIGNOZ_VALUE" {
|
||||
if expr, has := mc.orderByExpr(queryData); has {
|
||||
orderMap["columnName"] = expr
|
||||
}
|
||||
} else {
|
||||
// if the order by key is not part of the group by keys, remove it
|
||||
present := false
|
||||
|
||||
groupBy, ok := queryData["groupBy"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for idx := range groupBy {
|
||||
item, ok := groupBy[idx].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := item["key"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key == columnName {
|
||||
present = true
|
||||
}
|
||||
}
|
||||
|
||||
if !present {
|
||||
mc.logger.WarnContext(ctx, "found a order by without group by, skipping", slog.String("order_col_name", columnName))
|
||||
continue
|
||||
}
|
||||
}
|
||||
newOrderBy = append(newOrderBy, orderMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
queryData["orderBy"] = newOrderBy
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
|
||||
newOrderBy := make([]any, 0)
|
||||
for _, order := range orderBy {
|
||||
if orderMap, ok := order.(map[string]any); ok {
|
||||
columnName, _ := orderMap["columnName"].(string)
|
||||
// skip id and timestamp for (traces)
|
||||
if (columnName == "id" || columnName == "timestamp") && dataSource == "traces" {
|
||||
mc.logger.InfoContext(ctx, "skipping `id` order by for traces")
|
||||
continue
|
||||
}
|
||||
|
||||
// skip id for (logs)
|
||||
if (columnName == "id" || columnName == "timestamp") && dataSource == "logs" {
|
||||
mc.logger.InfoContext(ctx, "skipping `id`/`timestamp` order by for logs")
|
||||
continue
|
||||
}
|
||||
|
||||
newOrderBy = append(newOrderBy, orderMap)
|
||||
}
|
||||
}
|
||||
queryData["orderBy"] = newOrderBy
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only the `&& functionsArePreV5(functions)` guard differs from updateQueryData.
|
||||
if functions, ok := queryData["functions"].([]any); ok && functionsArePreV5(functions) {
|
||||
v5Functions := make([]any, len(functions))
|
||||
for i, fn := range functions {
|
||||
if fnMap, ok := fn.(map[string]any); ok {
|
||||
v5Function := map[string]any{
|
||||
"name": fnMap["name"],
|
||||
}
|
||||
|
||||
// Convert args from v4 format to v5 FunctionArg format
|
||||
if args, ok := fnMap["args"].([]any); ok {
|
||||
v5Args := make([]any, len(args))
|
||||
for j, arg := range args {
|
||||
// In v4, args were just values. In v5, they are FunctionArg objects
|
||||
v5Args[j] = map[string]any{
|
||||
"name": "", // v4 didn't have named args
|
||||
"value": arg,
|
||||
}
|
||||
}
|
||||
v5Function["args"] = v5Args
|
||||
}
|
||||
|
||||
// Handle namedArgs if present (some functions might have used this)
|
||||
if namedArgs, ok := fnMap["namedArgs"].(map[string]any); ok {
|
||||
// Convert named args to the new format
|
||||
existingArgs, _ := v5Function["args"].([]any)
|
||||
if existingArgs == nil {
|
||||
existingArgs = []any{}
|
||||
}
|
||||
|
||||
for name, value := range namedArgs {
|
||||
existingArgs = append(existingArgs, map[string]any{
|
||||
"name": name,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
v5Function["args"] = existingArgs
|
||||
}
|
||||
|
||||
v5Functions[i] = v5Function
|
||||
}
|
||||
}
|
||||
queryData["functions"] = v5Functions
|
||||
updated = true
|
||||
}
|
||||
|
||||
delete(queryData, "aggregateOperator")
|
||||
delete(queryData, "aggregateAttribute")
|
||||
delete(queryData, "temporality")
|
||||
delete(queryData, "timeAggregation")
|
||||
delete(queryData, "spaceAggregation")
|
||||
delete(queryData, "reduceTo")
|
||||
delete(queryData, "filters")
|
||||
delete(queryData, "ShiftBy")
|
||||
delete(queryData, "IsAnomaly")
|
||||
delete(queryData, "QueriesUsedInFormula")
|
||||
delete(queryData, "seriesAggregation")
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// createHavingExpressionShapeSafe copies createHavingExpression but leaves an
|
||||
// already-v5 having:{expression} alone instead of wiping it.
|
||||
func (mc *migrateCommon) createHavingExpressionShapeSafe(queryData map[string]any) bool {
|
||||
if _, ok := queryData["having"].(map[string]any); ok {
|
||||
return false // already v5-shaped
|
||||
}
|
||||
having, ok := queryData["having"].([]any)
|
||||
if !ok || len(having) == 0 {
|
||||
queryData["having"] = map[string]any{"expression": ""}
|
||||
return true
|
||||
}
|
||||
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
for idx := range having {
|
||||
if havingItem, ok := having[idx].(map[string]any); ok {
|
||||
havingCol, has := mc.orderByExpr(queryData)
|
||||
if has {
|
||||
havingItem["columnName"] = havingCol
|
||||
havingItem["key"] = map[string]any{"key": havingCol}
|
||||
}
|
||||
having[idx] = havingItem
|
||||
}
|
||||
}
|
||||
queryData["having"] = map[string]any{"expression": mc.buildExpression(context.Background(), having, "AND", dataSource)}
|
||||
return true
|
||||
}
|
||||
|
||||
// createAggregationsShapeSafe copies createAggregations but skips a query that
|
||||
// already has a v5 aggregations[], and picks the metric time/space aggregation
|
||||
// from the body's shape (has timeAggregation/spaceAggregation?) rather than the
|
||||
// version tag.
|
||||
func (mc *migrateCommon) createAggregationsShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
|
||||
return false // already v5-shaped
|
||||
}
|
||||
|
||||
aggregateOp, hasOp := queryData["aggregateOperator"].(string)
|
||||
aggregateAttr, hasAttr := queryData["aggregateAttribute"].(map[string]any)
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
if aggregateOp == "noop" && dataSource != "metrics" {
|
||||
return false
|
||||
}
|
||||
if !hasOp || !hasAttr {
|
||||
return false
|
||||
}
|
||||
|
||||
var aggregation map[string]any
|
||||
|
||||
switch dataSource {
|
||||
case "metrics":
|
||||
_, hasTime := queryData["timeAggregation"]
|
||||
_, hasSpace := queryData["spaceAggregation"]
|
||||
if hasTime || hasSpace { // acts as a check for v4 shape: the body carries its own time/space aggregation.
|
||||
if _, ok := queryData["spaceAggregation"]; !ok {
|
||||
queryData["spaceAggregation"] = aggregateOp
|
||||
}
|
||||
aggregation = map[string]any{
|
||||
"metricName": aggregateAttr["key"],
|
||||
"temporality": queryData["temporality"],
|
||||
"timeAggregation": queryData["timeAggregation"],
|
||||
"spaceAggregation": queryData["spaceAggregation"],
|
||||
}
|
||||
if reduceTo, ok := queryData["reduceTo"].(string); ok {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
}
|
||||
} else {
|
||||
// v3 shape: derive time/space from the compound operator.
|
||||
var timeAgg, spaceAgg, reduceTo string
|
||||
switch aggregateOp {
|
||||
case "sum_rate", "rate_sum":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
|
||||
case "avg_rate", "rate_avg":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "avg", "avg"
|
||||
case "min_rate", "rate_min":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "min", "min"
|
||||
case "max_rate", "rate_max":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "max", "max"
|
||||
case "hist_quantile_50":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p50", "avg"
|
||||
case "hist_quantile_75":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p75", "avg"
|
||||
case "hist_quantile_90":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p90", "avg"
|
||||
case "hist_quantile_95":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p95", "avg"
|
||||
case "hist_quantile_99":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p99", "avg"
|
||||
case "rate":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
|
||||
case "p99", "p90", "p75", "p50", "p25", "p20", "p10", "p05":
|
||||
mc.logger.InfoContext(ctx, "found invalid config")
|
||||
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
|
||||
case "min":
|
||||
timeAgg, spaceAgg, reduceTo = "min", "min", "min"
|
||||
case "max":
|
||||
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
|
||||
case "avg":
|
||||
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
|
||||
case "sum":
|
||||
timeAgg, spaceAgg, reduceTo = "sum", "sum", "sum"
|
||||
case "count":
|
||||
timeAgg, spaceAgg, reduceTo = "count", "sum", "sum"
|
||||
case "count_distinct":
|
||||
timeAgg, spaceAgg, reduceTo = "count_distinct", "sum", "sum"
|
||||
case "noop":
|
||||
mc.logger.WarnContext(ctx, "noop found in the aggregation data")
|
||||
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
|
||||
}
|
||||
aggregation = map[string]any{
|
||||
"metricName": aggregateAttr["key"],
|
||||
"temporality": queryData["temporality"],
|
||||
"timeAggregation": timeAgg,
|
||||
"spaceAggregation": spaceAgg,
|
||||
}
|
||||
if widgetType == "table" {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
} else if reduceTo, ok := queryData["reduceTo"].(string); ok {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
}
|
||||
}
|
||||
case "logs", "traces":
|
||||
aggregation = map[string]any{"expression": mc.buildAggregationExpression(aggregateOp, aggregateAttr)}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
queryData["aggregations"] = []any{aggregation}
|
||||
return true
|
||||
}
|
||||
|
||||
// orderByIsPreV5 reports whether an orderBy slice is still in the v4 shape (an
|
||||
// entry carries "columnName"); a v5 orderBy uses {key:{name}, direction}.
|
||||
func orderByIsPreV5(orderBy []any) bool {
|
||||
for _, o := range orderBy {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if _, has := m["columnName"]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// functionsArePreV5 reports whether a functions slice is still in the v4 shape
|
||||
// (args are raw values); a v5 function's args are {name,value} objects.
|
||||
func functionsArePreV5(functions []any) bool {
|
||||
for _, f := range functions {
|
||||
if m, ok := f.(map[string]any); ok {
|
||||
args, ok := m["args"].([]any)
|
||||
if !ok || len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
_, argIsObject := args[0].(map[string]any)
|
||||
return !argIsObject
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -22,6 +21,7 @@ var (
|
||||
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
|
||||
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
|
||||
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
|
||||
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
|
||||
)
|
||||
|
||||
type StorableDashboard struct {
|
||||
@@ -406,27 +406,26 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
|
||||
widgetData := data.Widgets[widgetIndex]
|
||||
switch widgetData.Query.QueryType {
|
||||
case "builder":
|
||||
migrate := transition.NewMigrateCommon(logger)
|
||||
for _, query := range widgetData.Query.Builder.QueryData {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryFormulas {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
}
|
||||
case "clickhouse_sql":
|
||||
for _, query := range widgetData.Query.ClickhouseSQL {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user