mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
3 Commits
issue-5535
...
ci/better-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bfd604aa4 | ||
|
|
14ba332935 | ||
|
|
fda7b66d49 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -56,8 +56,6 @@ jobs:
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
41
.github/workflows/jsci.yaml
vendored
41
.github/workflows/jsci.yaml
vendored
@@ -102,3 +102,44 @@ 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 }}
|
||||
|
||||
@@ -295,6 +295,18 @@
|
||||
// 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",
|
||||
{
|
||||
|
||||
18
frontend/.oxlintrc.strict.json
Normal file
18
frontend/.oxlintrc.strict.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$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,11 +12,17 @@ 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: [
|
||||
{
|
||||
|
||||
14
frontend/.stylelintrc.strict.cjs
Normal file
14
frontend/.stylelintrc.strict.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
3
frontend/__mocks__/lib/env.ts
Normal file
3
frontend/__mocks__/lib/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_DEV = false;
|
||||
export const IS_PROD = true;
|
||||
export const MODE = 'test';
|
||||
@@ -29,6 +29,7 @@ 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,6 +16,8 @@
|
||||
"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",
|
||||
|
||||
71
frontend/plugins/rules/max-component-lines.mjs
Normal file
71
frontend/plugins/rules/max-component-lines.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
48
frontend/plugins/rules/no-fireEvent.mjs
Normal file
48
frontend/plugins/rules/no-fireEvent.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
53
frontend/plugins/rules/no-getByText.mjs
Normal file
53
frontend/plugins/rules/no-getByText.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
161
frontend/plugins/rules/no-inline-helpers.mjs
Normal file
161
frontend/plugins/rules/no-inline-helpers.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
142
frontend/plugins/rules/no-multiple-components.mjs
Normal file
142
frontend/plugins/rules/no-multiple-components.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
39
frontend/plugins/rules/no-raw-html-text-elements.mjs
Normal file
39
frontend/plugins/rules/no-raw-html-text-elements.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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,6 +13,12 @@ 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: {
|
||||
@@ -27,5 +33,11 @@ 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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
|
||||
@@ -30,6 +31,33 @@ 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;
|
||||
@@ -110,6 +138,7 @@ 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
|
||||
@@ -146,37 +175,57 @@ 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'}
|
||||
>
|
||||
<span
|
||||
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
|
||||
<>
|
||||
<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'}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,17 @@ 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 } = deps;
|
||||
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
@@ -302,5 +309,17 @@ 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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.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%);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
.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;
|
||||
}
|
||||
108
frontend/src/lib/authz/devtools/AuthZDevModal/AuthZDevModal.tsx
Normal file
108
frontend/src/lib/authz/devtools/AuthZDevModal/AuthZDevModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.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);
|
||||
}
|
||||
114
frontend/src/lib/authz/devtools/AuthZDevModal/PermissionRow.tsx
Normal file
114
frontend/src/lib/authz/devtools/AuthZDevModal/PermissionRow.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
46
frontend/src/lib/authz/devtools/types.ts
Normal file
46
frontend/src/lib/authz/devtools/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
];
|
||||
137
frontend/src/lib/authz/devtools/useAuthZDevStore.ts
Normal file
137
frontend/src/lib/authz/devtools/useAuthZDevStore.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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();
|
||||
28
frontend/src/lib/authz/devtools/useAuthZQueryInvalidation.ts
Normal file
28
frontend/src/lib/authz/devtools/useAuthZQueryInvalidation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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,5 +89,13 @@ 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,5 +1,6 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
@@ -46,12 +47,16 @@ 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 () => {
|
||||
@@ -73,6 +78,89 @@ 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 () => {
|
||||
@@ -474,3 +562,120 @@ 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,13 +1,15 @@
|
||||
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 {
|
||||
import type {
|
||||
AuthZCheckResponse,
|
||||
BrandedPermission,
|
||||
UseAuthZOptions,
|
||||
@@ -17,6 +19,59 @@ 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[] = [];
|
||||
@@ -27,10 +82,11 @@ function dispatchPermission(
|
||||
pendingPermissions.push(permission);
|
||||
|
||||
if (!ctx) {
|
||||
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((r, re) => {
|
||||
resolve = r;
|
||||
reject = re;
|
||||
let promiseResolve: (v: AuthZCheckResponse) => void,
|
||||
promiseReject: (reason?: unknown) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((resolve, reject) => {
|
||||
promiseResolve = resolve;
|
||||
promiseReject = reject;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -38,7 +94,9 @@ function dispatchPermission(
|
||||
pendingPermissions = [];
|
||||
ctx = null;
|
||||
|
||||
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
|
||||
fetchManyPermissions(copiedPermissions)
|
||||
.then(promiseResolve)
|
||||
.catch(promiseReject);
|
||||
}, SINGLE_FLIGHT_WAIT_TIME_MS);
|
||||
}
|
||||
|
||||
@@ -85,19 +143,50 @@ 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 response = await dispatchPermission(permission);
|
||||
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
const fetchFn = async (): Promise<AuthZCheckResponse> => {
|
||||
const response = await dispatchPermission(permission);
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (IS_DEV) {
|
||||
return applyDevOverrideToQuery(permission, fetchFn);
|
||||
}
|
||||
|
||||
return fetchFn();
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -107,6 +196,7 @@ export function useAuthZ(
|
||||
() => queryResults.some((q) => q.isLoading),
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
const isFetching = useMemo(
|
||||
() => queryResults.some((q) => q.isFetching),
|
||||
[queryResults],
|
||||
@@ -139,15 +229,31 @@ export function useAuthZ(
|
||||
|
||||
const refetchPermissions = useCallback(() => {
|
||||
for (const query of queryResults) {
|
||||
query.refetch();
|
||||
void 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,6 +168,8 @@ export function mockUseAuthZGrantAll(
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: true }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: true,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -183,6 +185,8 @@ export function mockUseAuthZDenyAll(
|
||||
permissions: Object.fromEntries(
|
||||
permissions.map((p) => [p, { isGranted: false }]),
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: false,
|
||||
deniedPermissions: permissions,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -193,16 +197,23 @@ export function mockUseAuthZGrantByPrefix(
|
||||
permissions: BrandedPermission[],
|
||||
options?: UseAuthZOptions,
|
||||
) => UseAuthZResult {
|
||||
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(),
|
||||
});
|
||||
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(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
3
frontend/src/lib/env.ts
Normal file
3
frontend/src/lib/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
export const IS_PROD = import.meta.env.PROD;
|
||||
export const MODE = import.meta.env.MODE;
|
||||
@@ -63,8 +63,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -183,6 +188,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
.panelTypeCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--l2-background-hover);
|
||||
border-color: var(--bg-robin-400);
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.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,45 +1,93 @@
|
||||
import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
|
||||
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) => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
/** Section the picker opens on; omit → the first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="New Panel"
|
||||
footer={
|
||||
<PanelTypeSelectionModalFooter
|
||||
options={options}
|
||||
selectedValue={selectedValue}
|
||||
onSectionChange={setSelectedValue}
|
||||
isConfirmDisabled={selectedPanelKind === null}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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 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>
|
||||
</Modal>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -0,0 +1,55 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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,3 +14,16 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,8 +29,13 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
|
||||
/** Pass the target section's layout index; omit → last/new section. */
|
||||
openPicker: (layoutIndex?: number) => void;
|
||||
closePicker: () => void;
|
||||
createPanel: (panelKind: PanelKind) => 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind): void => {
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return { isPickerOpen, openPicker, closePicker, createPanel };
|
||||
return {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
targetLayoutIndex: layoutIndex,
|
||||
createPanel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
97
frontend/stylelint-rules/no-font-properties.js
Normal file
97
frontend/stylelint-rules/no-font-properties.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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 };
|
||||
87
frontend/stylelint-rules/no-primitive-color-tokens.js
Normal file
87
frontend/stylelint-rules/no-primitive-color-tokens.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 };
|
||||
59
frontend/stylelint-rules/no-styles-scss.js
Normal file
59
frontend/stylelint-rules/no-styles-scss.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -39,48 +39,48 @@ func TestReducedStatementBuilder(t *testing.T) {
|
||||
name: "gauge_sum_latest",
|
||||
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationLatest, metrictypes.SpaceAggregationSum),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge_avg_avg",
|
||||
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge_min_min",
|
||||
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMin, metrictypes.SpaceAggregationMin),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`min`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`min`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge_max_max",
|
||||
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMax, metrictypes.SpaceAggregationMax),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`max`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`max`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "counter_sum_rate",
|
||||
query: reducedQuery("test.metric.sum", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationSum),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "counter_avg_increase",
|
||||
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationIncrease, metrictypes.SpaceAggregationAvg),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), false, "test.metric", uint64(1746999600000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), "test.metric", uint64(1746999600000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -103,16 +103,16 @@ func TestReducedStatementBuilder(t *testing.T) {
|
||||
name: "histogram_p99",
|
||||
query: reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, `le`, argMax(`sum`, points.computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli, `le`) GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "summary_avg",
|
||||
query: reducedQuery("test.metric", metrictypes.SummaryType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT points.reduced_fingerprint AS fingerprint, points.unix_milli AS unix_milli, argMax(`sum_last`, points.computed_at) AS value, argMax(`count_series`, points.computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, unix_milli) GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), false, "test.metric", uint64(1746999900000), uint64(1747172760000)},
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
|
||||
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -337,28 +337,20 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
|
||||
}
|
||||
|
||||
// dedup recomputed buckets: latest computed_at wins per (series, 60s bucket)
|
||||
// TODO(srikanthccv): add _5m/_30m tables similar to samples_v4
|
||||
// and wrie them up in querier before GA
|
||||
// TODO(srikanthccv): FINAL clause for the reduced table.
|
||||
dedup := sqlbuilder.NewSelectBuilder()
|
||||
dedup.Select("points.reduced_fingerprint AS fingerprint", "points.unix_milli AS unix_milli")
|
||||
for _, g := range query.GroupBy {
|
||||
dedup.SelectMore(fmt.Sprintf("`%s`", g.Name))
|
||||
}
|
||||
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS value", value))
|
||||
dedup.Select("reduced_fingerprint AS fingerprint", "unix_milli")
|
||||
dedup.SelectMore(fmt.Sprintf("argMax(%s, computed_at) AS value", value))
|
||||
if weight != "" {
|
||||
dedup.SelectMore(fmt.Sprintf("argMax(%s, points.computed_at) AS weight", weight))
|
||||
dedup.SelectMore(fmt.Sprintf("argMax(%s, computed_at) AS weight", weight))
|
||||
}
|
||||
dedup.From(fmt.Sprintf("%s.%s AS points", DBName, WhichReducedSamplesTableToUse(agg.Type)))
|
||||
dedup.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
|
||||
dedup.From(fmt.Sprintf("%s.%s", DBName, WhichReducedSamplesTableToUse(agg.Type)))
|
||||
dedup.Where(
|
||||
dedup.In("metric_name", agg.MetricName),
|
||||
dedup.GTE("unix_milli", start),
|
||||
dedup.LT("unix_milli", end),
|
||||
)
|
||||
dedup.GroupBy("fingerprint", "unix_milli")
|
||||
dedup.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse, timeSeriesCTEArgs...)
|
||||
dedup.GroupBy("reduced_fingerprint", "unix_milli")
|
||||
dedupQuery, dedupArgs := dedup.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("fingerprint")
|
||||
@@ -372,11 +364,13 @@ func (b *MetricQueryStatementBuilder) buildReducedTemporalAggregationCTE(
|
||||
// denominator is reduced with avg
|
||||
sb.SelectMore("avg(weight) AS per_series_weight")
|
||||
}
|
||||
sb.From(fmt.Sprintf("(%s)", dedupQuery))
|
||||
sb.From(fmt.Sprintf("(%s) AS points", dedupQuery))
|
||||
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
|
||||
sb.GroupBy("fingerprint", "ts")
|
||||
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, dedupArgs...)
|
||||
initArgs := append(append([]any{}, dedupArgs...), timeSeriesCTEArgs...)
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, initArgs...)
|
||||
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, true
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ pytest_plugins = [
|
||||
"fixtures.postgres",
|
||||
"fixtures.sql",
|
||||
"fixtures.sqlite",
|
||||
"fixtures.keeper",
|
||||
"fixtures.zookeeper",
|
||||
"fixtures.signoz",
|
||||
"fixtures.audit",
|
||||
"fixtures.logs",
|
||||
@@ -80,6 +80,12 @@ def pytest_addoption(parser: pytest.Parser):
|
||||
default="25.5.6",
|
||||
help="clickhouse version",
|
||||
)
|
||||
parser.addoption(
|
||||
"--zookeeper-version",
|
||||
action="store",
|
||||
default="3.7.1",
|
||||
help="zookeeper version",
|
||||
)
|
||||
parser.addoption(
|
||||
"--schema-migrator-version",
|
||||
action="store",
|
||||
|
||||
425
tests/fixtures/clickhouse.py
vendored
425
tests/fixtures/clickhouse.py
vendored
@@ -2,7 +2,6 @@ import os
|
||||
from collections.abc import Callable, Generator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import clickhouse_connect
|
||||
import clickhouse_connect.driver
|
||||
@@ -18,88 +17,30 @@ from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLICKHOUSE_USERNAME = "signoz"
|
||||
CLICKHOUSE_PASSWORD = "password"
|
||||
|
||||
CUSTOM_FUNCTION_CONFIG = """
|
||||
<functions>
|
||||
<function>
|
||||
<type>executable</type>
|
||||
<name>histogramQuantile</name>
|
||||
<return_type>Float64</return_type>
|
||||
<argument>
|
||||
<type>Array(Float64)</type>
|
||||
<name>buckets</name>
|
||||
</argument>
|
||||
<argument>
|
||||
<type>Array(Float64)</type>
|
||||
<name>counts</name>
|
||||
</argument>
|
||||
<argument>
|
||||
<type>Float64</type>
|
||||
<name>quantile</name>
|
||||
</argument>
|
||||
<format>CSV</format>
|
||||
<command>./histogramQuantile</command>
|
||||
</function>
|
||||
</functions>
|
||||
"""
|
||||
|
||||
# Distributed inserts to a remote shard are async by default. We force
|
||||
# sycn at the profile level for deterministic tests.
|
||||
CLUSTER_USERS_CONFIG = """
|
||||
<clickhouse>
|
||||
<profiles>
|
||||
<default>
|
||||
<insert_distributed_sync>1</insert_distributed_sync>
|
||||
</default>
|
||||
</profiles>
|
||||
</clickhouse>
|
||||
"""
|
||||
|
||||
|
||||
def render_remote_servers(shard_hosts: list[tuple[str, int]], secret: str | None = None) -> str:
|
||||
"""Render the <remote_servers> block for a cluster named `cluster` with one
|
||||
single-replica shard per (host, port).
|
||||
@pytest.fixture(name="clickhouse", scope="package")
|
||||
def clickhouse(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
zookeeper: types.TestContainerDocker,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerClickhouse:
|
||||
"""
|
||||
Package-scoped fixture for Clickhouse TestContainer.
|
||||
"""
|
||||
shards = "".join(
|
||||
f"""
|
||||
<shard>
|
||||
<replica>
|
||||
<host>{host}</host>
|
||||
<port>{port}</port>
|
||||
</replica>
|
||||
</shard>"""
|
||||
for host, port in shard_hosts
|
||||
)
|
||||
|
||||
# Multi-node clusters need `secret` because distributed queries otherwise
|
||||
# authenticate as the `default` user, which the docker entrypoint restricts
|
||||
# to localhost when a custom user is configured.
|
||||
secret_block = (
|
||||
f"""
|
||||
<secret>{secret}</secret>"""
|
||||
if secret
|
||||
else ""
|
||||
)
|
||||
def create() -> types.TestContainerClickhouse:
|
||||
version = request.config.getoption("--clickhouse-version")
|
||||
|
||||
return f"""
|
||||
<remote_servers>
|
||||
<cluster>{secret_block}{shards}
|
||||
</cluster>
|
||||
</remote_servers>"""
|
||||
container = ClickHouseContainer(
|
||||
image=f"clickhouse/clickhouse-server:{version}",
|
||||
port=9000,
|
||||
username="signoz",
|
||||
password="password",
|
||||
)
|
||||
|
||||
|
||||
def render_node_config(
|
||||
keeper_address: str,
|
||||
keeper_port: int,
|
||||
shard: str,
|
||||
remote_servers: str,
|
||||
distributed_ddl_path: str = "/clickhouse/task_queue/ddl",
|
||||
) -> str:
|
||||
# <zookeeper> is ClickHouse's config section name for any coordination
|
||||
# service, including ClickHouse Keeper.
|
||||
return f"""
|
||||
cluster_config = f"""
|
||||
<clickhouse>
|
||||
<logger>
|
||||
<level>information</level>
|
||||
@@ -114,23 +55,33 @@ def render_node_config(
|
||||
</logger>
|
||||
|
||||
<macros>
|
||||
<shard>{shard}</shard>
|
||||
<shard>01</shard>
|
||||
<replica>01</replica>
|
||||
</macros>
|
||||
|
||||
<zookeeper>
|
||||
<node>
|
||||
<host>{keeper_address}</host>
|
||||
<port>{keeper_port}</port>
|
||||
<host>{zookeeper.container_configs["2181"].address}</host>
|
||||
<port>{zookeeper.container_configs["2181"].port}</port>
|
||||
</node>
|
||||
</zookeeper>
|
||||
{remote_servers}
|
||||
|
||||
<remote_servers>
|
||||
<cluster>
|
||||
<shard>
|
||||
<replica>
|
||||
<host>127.0.0.1</host>
|
||||
<port>9000</port>
|
||||
</replica>
|
||||
</shard>
|
||||
</cluster>
|
||||
</remote_servers>
|
||||
|
||||
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
|
||||
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
|
||||
|
||||
<distributed_ddl>
|
||||
<path>{distributed_ddl_path}</path>
|
||||
<path>/clickhouse/task_queue/ddl</path>
|
||||
<profile>default</profile>
|
||||
</distributed_ddl>
|
||||
|
||||
@@ -171,66 +122,38 @@ def render_node_config(
|
||||
</clickhouse>
|
||||
"""
|
||||
|
||||
custom_function_config = """
|
||||
<functions>
|
||||
<function>
|
||||
<type>executable</type>
|
||||
<name>histogramQuantile</name>
|
||||
<return_type>Float64</return_type>
|
||||
<argument>
|
||||
<type>Array(Float64)</type>
|
||||
<name>buckets</name>
|
||||
</argument>
|
||||
<argument>
|
||||
<type>Array(Float64)</type>
|
||||
<name>counts</name>
|
||||
</argument>
|
||||
<argument>
|
||||
<type>Float64</type>
|
||||
<name>quantile</name>
|
||||
</argument>
|
||||
<format>CSV</format>
|
||||
<command>./histogramQuantile</command>
|
||||
</function>
|
||||
</functions>
|
||||
"""
|
||||
|
||||
def install_histogram_quantile(container: ClickHouseContainer) -> None:
|
||||
wrapped = container.get_wrapped_container()
|
||||
exit_code, output = wrapped.exec_run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
(
|
||||
'version="v0.0.1" && '
|
||||
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
|
||||
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
|
||||
"cd /tmp && "
|
||||
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
|
||||
"tar -xzf histogram-quantile.tar.gz && "
|
||||
"mkdir -p /var/lib/clickhouse/user_scripts && "
|
||||
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
|
||||
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
|
||||
),
|
||||
],
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
|
||||
|
||||
|
||||
def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
keeper: types.TestContainerDocker,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "clickhouse",
|
||||
version: str | None = None,
|
||||
) -> types.TestContainerClickhouse:
|
||||
coordinator = next(iter(keeper.container_configs.values()))
|
||||
|
||||
def create() -> types.TestContainerClickhouse:
|
||||
clickhouse_version = version or request.config.getoption("--clickhouse-version")
|
||||
|
||||
container = ClickHouseContainer(
|
||||
image=f"clickhouse/clickhouse-server:{clickhouse_version}",
|
||||
port=9000,
|
||||
username=CLICKHOUSE_USERNAME,
|
||||
password=CLICKHOUSE_PASSWORD,
|
||||
)
|
||||
|
||||
cluster_config = render_node_config(
|
||||
keeper_address=coordinator.address,
|
||||
keeper_port=coordinator.port,
|
||||
shard="01",
|
||||
remote_servers=render_remote_servers([("127.0.0.1", 9000)]),
|
||||
)
|
||||
|
||||
tmp_dir = tmpfs(cache_key)
|
||||
tmp_dir = tmpfs("clickhouse")
|
||||
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
|
||||
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(cluster_config)
|
||||
|
||||
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
|
||||
with open(custom_function_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(CUSTOM_FUNCTION_CONFIG)
|
||||
f.write(custom_function_config)
|
||||
|
||||
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
|
||||
container.with_volume_mapping(
|
||||
@@ -240,7 +163,27 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
|
||||
container.with_network(network)
|
||||
container.start()
|
||||
|
||||
install_histogram_quantile(container)
|
||||
# Download and install the histogramQuantile binary
|
||||
wrapped = container.get_wrapped_container()
|
||||
exit_code, output = wrapped.exec_run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
(
|
||||
'version="v0.0.1" && '
|
||||
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
|
||||
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
|
||||
"cd /tmp && "
|
||||
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
|
||||
"tar -xzf histogram-quantile.tar.gz && "
|
||||
"mkdir -p /var/lib/clickhouse/user_scripts && "
|
||||
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
|
||||
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
|
||||
),
|
||||
],
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
|
||||
|
||||
connection = clickhouse_connect.get_client(
|
||||
user=container.username,
|
||||
@@ -310,7 +253,7 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
cache_key,
|
||||
"clickhouse",
|
||||
empty=lambda: types.TestContainerSQL(
|
||||
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
conn=None,
|
||||
@@ -322,212 +265,6 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="clickhouse", scope="package")
|
||||
def clickhouse(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
keeper: types.TestContainerDocker,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerClickhouse:
|
||||
"""
|
||||
Package-scoped fixture for Clickhouse TestContainer.
|
||||
"""
|
||||
return create_clickhouse(
|
||||
tmpfs=tmpfs,
|
||||
network=network,
|
||||
keeper=keeper,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="clickhouse_node_conns", scope="function")
|
||||
def clickhouse_node_conns(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[list[clickhouse_connect.driver.client.Client], Any]:
|
||||
"""Per-node clients (index 0 = the initiator) for asserting shard-local
|
||||
state via the local, non-distributed tables. Empty for single-node
|
||||
fixtures, which don't populate `nodes`."""
|
||||
conns = [
|
||||
clickhouse_connect.get_client(
|
||||
user=clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME"],
|
||||
password=clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD"],
|
||||
host=node.host_configs["8123"].address,
|
||||
port=node.host_configs["8123"].port,
|
||||
)
|
||||
for node in clickhouse.nodes
|
||||
]
|
||||
yield conns
|
||||
for conn in conns:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_clickhouse_cluster( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
keeper: types.TestContainerDocker,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "clickhouse_cluster",
|
||||
shards: int = 2,
|
||||
version: str | None = None,
|
||||
) -> types.TestContainerClickhouse:
|
||||
"""
|
||||
To some extent, taken inspiration from how ClickHouse's own integration
|
||||
harness composes real clusters: deterministic hostnames
|
||||
(network aliases), per-node shard macros, and a shared cluster definition
|
||||
named `cluster`.
|
||||
|
||||
`conn`/`env` point at node 1 i.e the initiator every query-service query and
|
||||
migration goes through. Per-node containers are exposed via `nodes` so
|
||||
tests can assert shard-local state.
|
||||
"""
|
||||
coordinator = next(iter(keeper.container_configs.values()))
|
||||
|
||||
def create() -> types.TestContainerClickhouse:
|
||||
clickhouse_version = version or request.config.getoption("--clickhouse-version")
|
||||
|
||||
# Unique aliases per creation: docker allows duplicate network aliases
|
||||
# (DNS round-robin), so a stale cluster must never share names with a
|
||||
# fresh one.
|
||||
suffix = uuid4().hex[:6]
|
||||
aliases = [f"signoz-ch-{suffix}-{i:02d}" for i in range(1, shards + 1)]
|
||||
remote_servers = render_remote_servers([(alias, 9000) for alias in aliases], secret=cache_key)
|
||||
# Own DDL queue path: the keeper instance may be shared with other
|
||||
# environments under --reuse; its DDL queue stays separate.
|
||||
distributed_ddl_path = f"/clickhouse/{cache_key}-{suffix}/task_queue/ddl"
|
||||
|
||||
nodes: list[types.TestContainerDocker] = []
|
||||
started: list[ClickHouseContainer] = []
|
||||
try:
|
||||
for i, alias in enumerate(aliases, start=1):
|
||||
node_config = render_node_config(
|
||||
keeper_address=coordinator.address,
|
||||
keeper_port=coordinator.port,
|
||||
shard=f"{i:02d}",
|
||||
remote_servers=remote_servers,
|
||||
distributed_ddl_path=distributed_ddl_path,
|
||||
)
|
||||
|
||||
tmp_dir = tmpfs(f"clickhouse-{suffix}-{i:02d}")
|
||||
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
|
||||
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(node_config)
|
||||
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
|
||||
with open(custom_function_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(CUSTOM_FUNCTION_CONFIG)
|
||||
users_config_file_path = os.path.join(tmp_dir, "users.xml")
|
||||
with open(users_config_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(CLUSTER_USERS_CONFIG)
|
||||
|
||||
container = ClickHouseContainer(
|
||||
image=f"clickhouse/clickhouse-server:{clickhouse_version}",
|
||||
port=9000,
|
||||
username=CLICKHOUSE_USERNAME,
|
||||
password=CLICKHOUSE_PASSWORD,
|
||||
)
|
||||
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
|
||||
container.with_volume_mapping(custom_function_file_path, "/etc/clickhouse-server/custom-function.xml")
|
||||
container.with_volume_mapping(users_config_file_path, "/etc/clickhouse-server/users.d/integration-cluster.xml")
|
||||
container.with_network(network)
|
||||
container.with_network_aliases(alias)
|
||||
container.start()
|
||||
started.append(container)
|
||||
|
||||
install_histogram_quantile(container)
|
||||
|
||||
nodes.append(
|
||||
types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
host_configs={
|
||||
"9000": types.TestContainerUrlConfig(
|
||||
"tcp",
|
||||
container.get_container_host_ip(),
|
||||
container.get_exposed_port(9000),
|
||||
),
|
||||
"8123": types.TestContainerUrlConfig(
|
||||
"tcp",
|
||||
container.get_container_host_ip(),
|
||||
container.get_exposed_port(8123),
|
||||
),
|
||||
},
|
||||
container_configs={
|
||||
"9000": types.TestContainerUrlConfig("tcp", alias, 9000),
|
||||
"8123": types.TestContainerUrlConfig("tcp", alias, 8123),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
for container in started:
|
||||
container.stop()
|
||||
raise
|
||||
|
||||
connection = clickhouse_connect.get_client(
|
||||
user=CLICKHOUSE_USERNAME,
|
||||
password=CLICKHOUSE_PASSWORD,
|
||||
host=nodes[0].host_configs["8123"].address,
|
||||
port=nodes[0].host_configs["8123"].port,
|
||||
)
|
||||
|
||||
return types.TestContainerClickhouse(
|
||||
container=nodes[0],
|
||||
conn=connection,
|
||||
env={
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": f"tcp://{CLICKHOUSE_USERNAME}:{CLICKHOUSE_PASSWORD}@{aliases[0]}:{9000}",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME": CLICKHOUSE_USERNAME,
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD": CLICKHOUSE_PASSWORD,
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
|
||||
},
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
def delete(resource: types.TestContainerClickhouse) -> None:
|
||||
client = docker.from_env()
|
||||
for node in resource.nodes or [resource.container]:
|
||||
try:
|
||||
client.containers.get(container_id=node.id).stop()
|
||||
client.containers.get(container_id=node.id).remove(v=True)
|
||||
except docker.errors.NotFound:
|
||||
logger.info(
|
||||
"Skipping removal of Clickhouse cluster node, node(%s) not found. Maybe it was manually removed?",
|
||||
{"id": node.id},
|
||||
)
|
||||
|
||||
def restore(cache: dict) -> types.TestContainerClickhouse:
|
||||
nodes = [types.TestContainerDocker.from_cache(node) for node in cache["nodes"]]
|
||||
env = cache["env"]
|
||||
host_config = nodes[0].host_configs["8123"]
|
||||
|
||||
conn = clickhouse_connect.get_client(
|
||||
user=env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_USERNAME"],
|
||||
password=env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_PASSWORD"],
|
||||
host=host_config.address,
|
||||
port=host_config.port,
|
||||
)
|
||||
|
||||
return types.TestContainerClickhouse(
|
||||
container=nodes[0],
|
||||
conn=conn,
|
||||
env=env,
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
cache_key,
|
||||
empty=lambda: types.TestContainerClickhouse(
|
||||
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
conn=None,
|
||||
env={},
|
||||
),
|
||||
create=create,
|
||||
delete=delete,
|
||||
restore=restore,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="check_query_log")
|
||||
def check_query_log(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
121
tests/fixtures/keeper.py
vendored
121
tests/fixtures/keeper.py
vendored
@@ -1,121 +0,0 @@
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
KEEPER_CONFIG = """
|
||||
<clickhouse>
|
||||
<listen_host>0.0.0.0</listen_host>
|
||||
<keeper_server>
|
||||
<tcp_port>9181</tcp_port>
|
||||
<server_id>1</server_id>
|
||||
<log_storage_path>/var/lib/clickhouse-keeper/coordination/log</log_storage_path>
|
||||
<snapshot_storage_path>/var/lib/clickhouse-keeper/coordination/snapshots</snapshot_storage_path>
|
||||
<coordination_settings>
|
||||
<operation_timeout_ms>10000</operation_timeout_ms>
|
||||
<session_timeout_ms>30000</session_timeout_ms>
|
||||
<raft_logs_level>warning</raft_logs_level>
|
||||
</coordination_settings>
|
||||
<raft_configuration>
|
||||
<server>
|
||||
<id>1</id>
|
||||
<hostname>localhost</hostname>
|
||||
<port>9234</port>
|
||||
</server>
|
||||
</raft_configuration>
|
||||
</keeper_server>
|
||||
</clickhouse>
|
||||
"""
|
||||
|
||||
|
||||
def create_clickhouse_keeper(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "clickhousekeeper",
|
||||
version: str | None = None,
|
||||
) -> types.TestContainerDocker:
|
||||
|
||||
def create() -> types.TestContainerDocker:
|
||||
keeper_version = version or request.config.getoption("--clickhouse-version")
|
||||
|
||||
tmp_dir = tmpfs(cache_key)
|
||||
keeper_config_file_path = os.path.join(tmp_dir, "keeper_config.xml")
|
||||
with open(keeper_config_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(KEEPER_CONFIG)
|
||||
|
||||
container = DockerContainer(image=f"clickhouse/clickhouse-keeper:{keeper_version}")
|
||||
container.with_volume_mapping(keeper_config_file_path, "/etc/clickhouse-keeper/keeper_config.xml")
|
||||
container.with_exposed_ports(9181)
|
||||
container.with_network(network=network)
|
||||
|
||||
container.start()
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
host_configs={
|
||||
"9181": types.TestContainerUrlConfig(
|
||||
scheme="tcp",
|
||||
address=container.get_container_host_ip(),
|
||||
port=container.get_exposed_port(9181),
|
||||
)
|
||||
},
|
||||
container_configs={
|
||||
"9181": types.TestContainerUrlConfig(
|
||||
scheme="tcp",
|
||||
address=container.get_wrapped_container().name,
|
||||
port=9181,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.containers.get(container_id=container.id).stop()
|
||||
client.containers.get(container_id=container.id).remove(v=True)
|
||||
except docker.errors.NotFound:
|
||||
logger.info(
|
||||
"Skipping removal of ClickHouse Keeper, Keeper(%s) not found. Maybe it was manually removed?",
|
||||
{"id": container.id},
|
||||
)
|
||||
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
cache_key,
|
||||
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="keeper", scope="package")
|
||||
def keeper(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
"""
|
||||
Package-scoped fixture for ClickHouse Keeper TestContainer.
|
||||
"""
|
||||
return create_clickhouse_keeper(
|
||||
tmpfs=tmpfs,
|
||||
network=network,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
)
|
||||
83
tests/fixtures/metricreduction.py
vendored
83
tests/fixtures/metricreduction.py
vendored
@@ -1,83 +0,0 @@
|
||||
import datetime
|
||||
from collections.abc import Sequence
|
||||
|
||||
import clickhouse_connect.driver.client
|
||||
|
||||
from fixtures.metrics import MetricsBufferSample, MetricsBufferTimeSeries
|
||||
|
||||
|
||||
def local_series_counts(
|
||||
node_conns: list[clickhouse_connect.driver.client.Client],
|
||||
table: str,
|
||||
metric_name: str,
|
||||
) -> list[int]:
|
||||
"""Distinct series per node via the LOCAL (non-distributed) table."""
|
||||
return [
|
||||
int(
|
||||
conn.query(
|
||||
f"SELECT count(DISTINCT fingerprint) FROM signoz_metrics.{table} WHERE metric_name = %(metric_name)s",
|
||||
parameters={"metric_name": metric_name},
|
||||
).result_rows[0][0]
|
||||
)
|
||||
for conn in node_conns
|
||||
]
|
||||
|
||||
|
||||
def assert_spans_shards(
|
||||
node_conns: list[clickhouse_connect.driver.client.Client],
|
||||
table: str,
|
||||
metric_name: str,
|
||||
total: int,
|
||||
) -> None:
|
||||
"""Guard for distributed tests: a green run on a cluster proves nothing
|
||||
unless the seeded series actually landed on more than one shard."""
|
||||
counts = local_series_counts(node_conns, table, metric_name)
|
||||
assert sum(counts) == total, f"expected {total} series in {table} across shards, got {counts}"
|
||||
assert min(counts) > 0, f"seeded series in {table} all landed on one shard: {counts}"
|
||||
|
||||
|
||||
def build_recent_gauge_data(
|
||||
metric_name: str,
|
||||
base_epoch: int,
|
||||
services: Sequence[str],
|
||||
pods_per_service: int,
|
||||
minutes: int,
|
||||
value: float = 1.0,
|
||||
) -> tuple[list[MetricsBufferTimeSeries], list[MetricsBufferSample]]:
|
||||
"""Collector-shaped buffer rows for a gauge under a reduction rule that
|
||||
keeps `service`: per raw series a raw series row (is_reduced=false, full
|
||||
labels, reduced_fingerprint -> group) plus the group's reduced series row
|
||||
(is_reduced=true, kept labels), and one raw sample per series per minute
|
||||
carrying both fingerprints. Returns (time_series, samples) for
|
||||
insert_buffer_metrics."""
|
||||
reduced_series = {
|
||||
service: MetricsBufferTimeSeries(
|
||||
metric_name=metric_name,
|
||||
labels={"service": service},
|
||||
timestamp=datetime.datetime.fromtimestamp(base_epoch, tz=datetime.UTC),
|
||||
is_reduced=True,
|
||||
)
|
||||
for service in services
|
||||
}
|
||||
raw_series = [
|
||||
MetricsBufferTimeSeries(
|
||||
metric_name=metric_name,
|
||||
labels={"service": service, "pod": f"pod-{service}-{i}"},
|
||||
timestamp=datetime.datetime.fromtimestamp(base_epoch, tz=datetime.UTC),
|
||||
reduced_fingerprint=reduced_series[service].fingerprint,
|
||||
)
|
||||
for service in services
|
||||
for i in range(pods_per_service)
|
||||
]
|
||||
samples = [
|
||||
MetricsBufferSample(
|
||||
metric_name=metric_name,
|
||||
fingerprint=ts.fingerprint,
|
||||
timestamp=datetime.datetime.fromtimestamp(base_epoch + minute * 60, tz=datetime.UTC),
|
||||
value=value,
|
||||
reduced_fingerprint=ts.reduced_fingerprint,
|
||||
)
|
||||
for ts in raw_series
|
||||
for minute in range(minutes)
|
||||
]
|
||||
return raw_series + list(reduced_series.values()), samples
|
||||
426
tests/fixtures/metrics.py
vendored
426
tests/fixtures/metrics.py
vendored
@@ -11,14 +11,6 @@ import pytest
|
||||
from fixtures import types
|
||||
from fixtures.time import parse_timestamp
|
||||
|
||||
_REDUCED_METRICS_TABLES_TO_TRUNCATE = [
|
||||
"time_series_v4_reduced",
|
||||
"samples_v4_reduced_last_60s",
|
||||
"samples_v4_reduced_sum_60s",
|
||||
"time_series_v4_buffer",
|
||||
"samples_v4_buffer",
|
||||
]
|
||||
|
||||
|
||||
class MetricsTimeSeries(ABC):
|
||||
"""Represents a row in the time_series_v4 table."""
|
||||
@@ -422,267 +414,6 @@ class Metrics(ABC):
|
||||
return metrics
|
||||
|
||||
|
||||
class MetricsReducedTimeSeries(ABC):
|
||||
"""Represents a row in the time_series_v4_reduced table i.e what
|
||||
the time_series_v4_reduced_mv materializes for a metric under a
|
||||
reduction rule. One row per kept-label group. `fingerprint` holds the
|
||||
reduced fingerprint and `labels` contains only the kept labels.
|
||||
|
||||
The fingerprint recipe (md5, like MetricsTimeSeries) does not match the
|
||||
collector's real hash; it only needs to be consistent with the
|
||||
reduced_fingerprint used in the reduced samples rows.
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
metric_name: str,
|
||||
kept_labels: dict[str, str],
|
||||
timestamp: datetime.datetime,
|
||||
temporality: str = "Unspecified",
|
||||
description: str = "",
|
||||
unit: str = "",
|
||||
type_: str = "Gauge",
|
||||
is_monotonic: bool = False,
|
||||
env: str = "default",
|
||||
) -> None:
|
||||
kept_labels = dict(kept_labels)
|
||||
kept_labels["__name__"] = metric_name
|
||||
self.env = env
|
||||
# mirror time_series_v4_reduced_mv: monotonic cumulative counters are
|
||||
# reduced as deltas
|
||||
if temporality == "Cumulative" and is_monotonic:
|
||||
temporality = "Delta"
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.description = description
|
||||
self.unit = unit
|
||||
self.type = type_
|
||||
self.is_monotonic = is_monotonic
|
||||
self.labels = json.dumps(kept_labels, separators=(",", ":"))
|
||||
self.attrs = kept_labels
|
||||
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.normalized = False
|
||||
|
||||
fingerprint_str = metric_name + self.labels
|
||||
self.fingerprint = np.uint64(int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16))
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.env,
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.description,
|
||||
self.unit,
|
||||
self.type,
|
||||
self.is_monotonic,
|
||||
self.fingerprint,
|
||||
self.unix_milli,
|
||||
self.labels,
|
||||
self.attrs,
|
||||
{},
|
||||
{},
|
||||
self.normalized,
|
||||
]
|
||||
|
||||
|
||||
class MetricsReducedSampleLast60s(ABC):
|
||||
"""Represents a row in the samples_v4_reduced_last_60s table. One 60s
|
||||
bucket per reduced group, as the samples_v4_reduced_last_60s_mv refresh
|
||||
would emit it (gauges and non-monotonic cumulative sums)."""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
metric_name: str,
|
||||
reduced_fingerprint: np.uint64,
|
||||
timestamp: datetime.datetime,
|
||||
sum_last: float,
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
sum_values: float,
|
||||
count_series: int,
|
||||
count_samples: int,
|
||||
temporality: str = "Unspecified",
|
||||
env: str = "default",
|
||||
computed_at: datetime.datetime | None = None,
|
||||
) -> None:
|
||||
self.env = env
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.reduced_fingerprint = reduced_fingerprint
|
||||
# buckets are 60s-aligned: intDiv(unix_milli, 60000) * 60000
|
||||
self.unix_milli = np.int64((int(timestamp.timestamp() * 1e3) // 60000) * 60000)
|
||||
self.sum_last = np.float64(sum_last)
|
||||
self.min = np.float64(min_value)
|
||||
self.max = np.float64(max_value)
|
||||
self.sum_values = np.float64(sum_values)
|
||||
self.count_series = np.uint64(count_series)
|
||||
self.count_samples = np.uint64(count_samples)
|
||||
# the refresh stamps now(); default to shortly after the bucket closes
|
||||
if computed_at is None:
|
||||
computed_at = datetime.datetime.fromtimestamp(int(self.unix_milli) / 1e3, tz=datetime.UTC) + datetime.timedelta(seconds=180)
|
||||
self.computed_at = computed_at
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.env,
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.reduced_fingerprint,
|
||||
self.unix_milli,
|
||||
self.sum_last,
|
||||
self.min,
|
||||
self.max,
|
||||
self.sum_values,
|
||||
self.count_series,
|
||||
self.count_samples,
|
||||
self.computed_at,
|
||||
]
|
||||
|
||||
|
||||
class MetricsReducedSampleSum60s(ABC):
|
||||
"""Represents a row in the samples_v4_reduced_sum_60s table. One 60s
|
||||
bucket per reduced group for delta counters and histograms."""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
metric_name: str,
|
||||
reduced_fingerprint: np.uint64,
|
||||
timestamp: datetime.datetime,
|
||||
sum_value: float,
|
||||
count_series: int,
|
||||
count_samples: int,
|
||||
temporality: str = "Delta",
|
||||
env: str = "default",
|
||||
computed_at: datetime.datetime | None = None,
|
||||
) -> None:
|
||||
self.env = env
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.reduced_fingerprint = reduced_fingerprint
|
||||
self.unix_milli = np.int64((int(timestamp.timestamp() * 1e3) // 60000) * 60000)
|
||||
self.sum = np.float64(sum_value)
|
||||
self.count_series = np.uint64(count_series)
|
||||
self.count_samples = np.uint64(count_samples)
|
||||
if computed_at is None:
|
||||
computed_at = datetime.datetime.fromtimestamp(int(self.unix_milli) / 1e3, tz=datetime.UTC) + datetime.timedelta(seconds=180)
|
||||
self.computed_at = computed_at
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.env,
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.reduced_fingerprint,
|
||||
self.unix_milli,
|
||||
self.sum,
|
||||
self.count_series,
|
||||
self.count_samples,
|
||||
self.computed_at,
|
||||
]
|
||||
|
||||
|
||||
class MetricsBufferTimeSeries(ABC):
|
||||
"""Represents a row in the time_series_v4_buffer table. This is the collector's
|
||||
universal landing target under cardinality control. For a ruled metric the
|
||||
collector writes two rows per series: the raw one (is_reduced=false, full
|
||||
labels, reduced_fingerprint pointing at its group) and the group's reduced
|
||||
one (is_reduced=true, kept labels, fingerprint = reduced fingerprint)."""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
metric_name: str,
|
||||
labels: dict[str, str],
|
||||
timestamp: datetime.datetime,
|
||||
reduced_fingerprint: np.uint64 | int = 0,
|
||||
is_reduced: bool = False,
|
||||
temporality: str = "Unspecified",
|
||||
description: str = "",
|
||||
unit: str = "",
|
||||
type_: str = "Gauge",
|
||||
is_monotonic: bool = False,
|
||||
env: str = "default",
|
||||
) -> None:
|
||||
labels = dict(labels)
|
||||
labels["__name__"] = metric_name
|
||||
self.env = env
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.description = description
|
||||
self.unit = unit
|
||||
self.type = type_
|
||||
self.is_monotonic = is_monotonic
|
||||
self.reduced_fingerprint = np.uint64(reduced_fingerprint)
|
||||
self.is_reduced = is_reduced
|
||||
self.labels = json.dumps(labels, separators=(",", ":"))
|
||||
self.attrs = labels
|
||||
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.normalized = False
|
||||
|
||||
fingerprint_str = metric_name + self.labels
|
||||
self.fingerprint = np.uint64(int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16))
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.env,
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.description,
|
||||
self.unit,
|
||||
self.type,
|
||||
self.is_monotonic,
|
||||
self.fingerprint,
|
||||
self.reduced_fingerprint,
|
||||
self.is_reduced,
|
||||
self.unix_milli,
|
||||
self.labels,
|
||||
self.attrs,
|
||||
{},
|
||||
{},
|
||||
self.normalized,
|
||||
]
|
||||
|
||||
|
||||
class MetricsBufferSample(ABC):
|
||||
"""Represents a row in the samples_v4_buffer table. Ruled samples carry
|
||||
the raw fingerprint plus the group's reduced_fingerprint; unruled samples
|
||||
have reduced_fingerprint = 0."""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
metric_name: str,
|
||||
fingerprint: np.uint64,
|
||||
timestamp: datetime.datetime,
|
||||
value: float,
|
||||
reduced_fingerprint: np.uint64 | int = 0,
|
||||
is_monotonic: bool = False,
|
||||
temporality: str = "Unspecified",
|
||||
env: str = "default",
|
||||
flags: int = 0,
|
||||
) -> None:
|
||||
self.env = env
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.fingerprint = fingerprint
|
||||
self.reduced_fingerprint = np.uint64(reduced_fingerprint)
|
||||
self.is_monotonic = is_monotonic
|
||||
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.value = np.float64(value)
|
||||
self.flags = np.uint32(flags)
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.env,
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.fingerprint,
|
||||
self.reduced_fingerprint,
|
||||
self.is_monotonic,
|
||||
self.unix_milli,
|
||||
self.value,
|
||||
self.flags,
|
||||
]
|
||||
|
||||
|
||||
def insert_metrics_to_clickhouse(conn, metrics: list[Metrics]) -> None:
|
||||
"""
|
||||
Insert metrics into ClickHouse tables.
|
||||
@@ -845,163 +576,6 @@ def insert_metrics(
|
||||
)
|
||||
|
||||
|
||||
def insert_reduced_metrics_to_clickhouse(
|
||||
conn,
|
||||
time_series: list[MetricsReducedTimeSeries],
|
||||
last_samples: list[MetricsReducedSampleLast60s] | None = None,
|
||||
sum_samples: list[MetricsReducedSampleSum60s] | None = None,
|
||||
) -> None:
|
||||
"""Insert reduced series into distributed_time_series_v4_reduced and 60s
|
||||
buckets into the reduced samples tables. These tables exist only when
|
||||
the schema migrator version includes the metrics cardinality-control
|
||||
migration."""
|
||||
if time_series:
|
||||
conn.insert(
|
||||
database="signoz_metrics",
|
||||
table="distributed_time_series_v4_reduced",
|
||||
column_names=[
|
||||
"env",
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"description",
|
||||
"unit",
|
||||
"type",
|
||||
"is_monotonic",
|
||||
"fingerprint",
|
||||
"unix_milli",
|
||||
"labels",
|
||||
"attrs",
|
||||
"scope_attrs",
|
||||
"resource_attrs",
|
||||
"__normalized",
|
||||
],
|
||||
data=[ts.to_row() for ts in time_series],
|
||||
)
|
||||
|
||||
if last_samples:
|
||||
conn.insert(
|
||||
database="signoz_metrics",
|
||||
table="distributed_samples_v4_reduced_last_60s",
|
||||
column_names=[
|
||||
"env",
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"reduced_fingerprint",
|
||||
"unix_milli",
|
||||
"sum_last",
|
||||
"min",
|
||||
"max",
|
||||
"sum_values",
|
||||
"count_series",
|
||||
"count_samples",
|
||||
"computed_at",
|
||||
],
|
||||
data=[sample.to_row() for sample in last_samples],
|
||||
)
|
||||
|
||||
if sum_samples:
|
||||
conn.insert(
|
||||
database="signoz_metrics",
|
||||
table="distributed_samples_v4_reduced_sum_60s",
|
||||
column_names=[
|
||||
"env",
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"reduced_fingerprint",
|
||||
"unix_milli",
|
||||
"sum",
|
||||
"count_series",
|
||||
"count_samples",
|
||||
"computed_at",
|
||||
],
|
||||
data=[sample.to_row() for sample in sum_samples],
|
||||
)
|
||||
|
||||
|
||||
def insert_buffer_metrics_to_clickhouse(
|
||||
conn,
|
||||
time_series: list[MetricsBufferTimeSeries],
|
||||
samples: list[MetricsBufferSample],
|
||||
) -> None:
|
||||
if time_series:
|
||||
conn.insert(
|
||||
database="signoz_metrics",
|
||||
table="distributed_time_series_v4_buffer",
|
||||
column_names=[
|
||||
"env",
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"description",
|
||||
"unit",
|
||||
"type",
|
||||
"is_monotonic",
|
||||
"fingerprint",
|
||||
"reduced_fingerprint",
|
||||
"is_reduced",
|
||||
"unix_milli",
|
||||
"labels",
|
||||
"attrs",
|
||||
"scope_attrs",
|
||||
"resource_attrs",
|
||||
"__normalized",
|
||||
],
|
||||
data=[ts.to_row() for ts in time_series],
|
||||
)
|
||||
|
||||
if samples:
|
||||
conn.insert(
|
||||
database="signoz_metrics",
|
||||
table="distributed_samples_v4_buffer",
|
||||
column_names=[
|
||||
"env",
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"fingerprint",
|
||||
"reduced_fingerprint",
|
||||
"is_monotonic",
|
||||
"unix_milli",
|
||||
"value",
|
||||
"flags",
|
||||
],
|
||||
data=[sample.to_row() for sample in samples],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="insert_reduced_metrics", scope="function")
|
||||
def insert_reduced_metrics(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[Callable[..., None], Any]:
|
||||
def _insert_reduced_metrics(
|
||||
time_series: list[MetricsReducedTimeSeries],
|
||||
last_samples: list[MetricsReducedSampleLast60s] | None = None,
|
||||
sum_samples: list[MetricsReducedSampleSum60s] | None = None,
|
||||
) -> None:
|
||||
insert_reduced_metrics_to_clickhouse(clickhouse.conn, time_series, last_samples, sum_samples)
|
||||
|
||||
yield _insert_reduced_metrics
|
||||
|
||||
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
for table in _REDUCED_METRICS_TABLES_TO_TRUNCATE:
|
||||
clickhouse.conn.query(f"TRUNCATE TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' SYNC")
|
||||
|
||||
|
||||
@pytest.fixture(name="insert_buffer_metrics", scope="function")
|
||||
def insert_buffer_metrics(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[Callable[..., None], Any]:
|
||||
def _insert_buffer_metrics(
|
||||
time_series: list[MetricsBufferTimeSeries],
|
||||
samples: list[MetricsBufferSample],
|
||||
) -> None:
|
||||
insert_buffer_metrics_to_clickhouse(clickhouse.conn, time_series, samples)
|
||||
|
||||
yield _insert_buffer_metrics
|
||||
|
||||
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
for table in _REDUCED_METRICS_TABLES_TO_TRUNCATE:
|
||||
clickhouse.conn.query(f"TRUNCATE TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' SYNC")
|
||||
|
||||
|
||||
@pytest.fixture(name="remove_metrics_ttl_and_storage_settings", scope="function")
|
||||
def remove_metrics_ttl_and_storage_settings(signoz: types.SigNoz):
|
||||
"""
|
||||
|
||||
13
tests/fixtures/migrator.py
vendored
13
tests/fixtures/migrator.py
vendored
@@ -8,30 +8,27 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def create_migrator( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def create_migrator(
|
||||
network: Network,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "migrator",
|
||||
env_overrides: dict | None = None,
|
||||
version: str | None = None,
|
||||
) -> types.Operation:
|
||||
"""
|
||||
Factory function for running schema migrations.
|
||||
Accepts optional env_overrides to customize the migrator environment, and
|
||||
an optional version to pin a schema-migrator release different from the
|
||||
--schema-migrator-version option.
|
||||
Accepts optional env_overrides to customize the migrator environment.
|
||||
"""
|
||||
|
||||
def create() -> None:
|
||||
migrator_version = version or request.config.getoption("--schema-migrator-version")
|
||||
version = request.config.getoption("--schema-migrator-version")
|
||||
client = docker.from_env()
|
||||
|
||||
environment = dict(env_overrides) if env_overrides else {}
|
||||
|
||||
container = client.containers.run(
|
||||
image=f"signoz/signoz-schema-migrator:{migrator_version}",
|
||||
image=f"signoz/signoz-schema-migrator:{version}",
|
||||
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
|
||||
detach=True,
|
||||
auto_remove=False,
|
||||
@@ -50,7 +47,7 @@ def create_migrator( # pylint: disable=too-many-arguments,too-many-positional-a
|
||||
container.remove()
|
||||
|
||||
container = client.containers.run(
|
||||
image=f"signoz/signoz-schema-migrator:{migrator_version}",
|
||||
image=f"signoz/signoz-schema-migrator:{version}",
|
||||
command=f"async --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
|
||||
detach=True,
|
||||
auto_remove=False,
|
||||
|
||||
29
tests/fixtures/querier.py
vendored
29
tests/fixtures/querier.py
vendored
@@ -189,35 +189,6 @@ def make_query_request(
|
||||
)
|
||||
|
||||
|
||||
def aligned_epoch(ago: timedelta, step_seconds: int = DEFAULT_STEP_INTERVAL) -> int:
|
||||
"""Epoch seconds for `now - ago`, floored to a step boundary so seeded
|
||||
points land exactly on the query's toStartOfInterval buckets."""
|
||||
return (int((datetime.now(tz=UTC) - ago).timestamp()) // step_seconds) * step_seconds
|
||||
|
||||
|
||||
def query_metric_values( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
metric_name: str,
|
||||
start_epoch: int,
|
||||
end_epoch: int,
|
||||
time_agg: str,
|
||||
space_agg: str,
|
||||
step_interval: int = DEFAULT_STEP_INTERVAL,
|
||||
) -> list[dict]:
|
||||
"""Run a single metrics builder query over [start_epoch, end_epoch) in
|
||||
epoch seconds and return its series values sorted by timestamp."""
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_epoch * 1000,
|
||||
end_ms=end_epoch * 1000,
|
||||
queries=[build_builder_query("A", metric_name, time_agg, space_agg, step_interval=step_interval)],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
return sorted(get_series_values(response.json(), "A"), key=lambda v: v["timestamp"])
|
||||
|
||||
|
||||
def build_builder_query(
|
||||
name: str,
|
||||
metric_name: str,
|
||||
|
||||
7
tests/fixtures/types.py
vendored
7
tests/fixtures/types.py
vendored
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -84,16 +84,11 @@ class TestContainerClickhouse:
|
||||
container: TestContainerDocker
|
||||
conn: clickhouse_connect.driver.client.Client
|
||||
env: dict[str, str]
|
||||
# Per-node containers when running a multi-node cluster. Empty for the
|
||||
# default single-node setup; nodes[0] is the node `conn`/`env` point at
|
||||
# (the initiator every query goes through).
|
||||
nodes: list[TestContainerDocker] = field(default_factory=list)
|
||||
|
||||
def __cache__(self) -> dict:
|
||||
return {
|
||||
"container": self.container.__cache__(),
|
||||
"env": self.env,
|
||||
"nodes": [node.__cache__() for node in self.nodes],
|
||||
}
|
||||
|
||||
def __log__(self) -> str:
|
||||
|
||||
67
tests/fixtures/zookeeper.py
vendored
Normal file
67
tests/fixtures/zookeeper.py
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(name="zookeeper", scope="package")
|
||||
def zookeeper(network: Network, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.TestContainerDocker:
|
||||
"""
|
||||
Package-scoped fixture for Zookeeper TestContainer.
|
||||
"""
|
||||
|
||||
def create() -> types.TestContainerDocker:
|
||||
version = request.config.getoption("--zookeeper-version")
|
||||
|
||||
container = DockerContainer(image=f"signoz/zookeeper:{version}")
|
||||
container.with_env("ALLOW_ANONYMOUS_LOGIN", "yes")
|
||||
container.with_exposed_ports(2181)
|
||||
container.with_network(network=network)
|
||||
|
||||
container.start()
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
host_configs={
|
||||
"2181": types.TestContainerUrlConfig(
|
||||
scheme="tcp",
|
||||
address=container.get_container_host_ip(),
|
||||
port=container.get_exposed_port(2181),
|
||||
)
|
||||
},
|
||||
container_configs={
|
||||
"2181": types.TestContainerUrlConfig(
|
||||
scheme="tcp",
|
||||
address=container.get_wrapped_container().name,
|
||||
port=2181,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.containers.get(container_id=container.id).stop()
|
||||
client.containers.get(container_id=container.id).remove(v=True)
|
||||
except docker.errors.NotFound:
|
||||
logger.info(
|
||||
"Skipping removal of Zookeeper, Zookeeper(%s) not found. Maybe it was manually removed?",
|
||||
{"id": container.id},
|
||||
)
|
||||
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"zookeeper",
|
||||
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
import clickhouse_connect.driver.client
|
||||
|
||||
from fixtures import types
|
||||
|
||||
TOTAL_ROWS = 64
|
||||
|
||||
|
||||
def test_topology(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
clickhouse_node_conns: list[clickhouse_connect.driver.client.Client],
|
||||
) -> None:
|
||||
aliases = {node.container_configs["9000"].address for node in clickhouse.nodes}
|
||||
|
||||
# Every node sees the same 2-shard cluster definition and identifies
|
||||
# exactly itself as the local replica
|
||||
|
||||
for i, conn in enumerate(clickhouse_node_conns, start=1):
|
||||
rows = conn.query("SELECT shard_num, host_name, is_local FROM system.clusters WHERE cluster = 'cluster' ORDER BY shard_num").result_rows
|
||||
assert [row[0] for row in rows] == [1, 2], f"node {i}: expected 2 shards, got {rows}"
|
||||
assert {row[1] for row in rows} == aliases, f"node {i}: cluster hosts {rows} != node aliases {aliases}"
|
||||
local = [row[0] for row in rows if row[2]]
|
||||
assert local == [i], f"node {i}: expected to be local for shard {i} only, got {local}"
|
||||
|
||||
|
||||
def test_replicated_distributed_round_trip(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
clickhouse_node_conns: list[clickhouse_connect.driver.client.Client],
|
||||
) -> None:
|
||||
# ON CLUSTER DDL reaches both nodes, Replicated engines register with the
|
||||
# keeper via per-node macros, and a sharded Distributed insert scatters rows
|
||||
# across shards while the distributed read returns the union.
|
||||
conn = clickhouse.conn
|
||||
try:
|
||||
conn.query("CREATE DATABASE IF NOT EXISTS it_cluster ON CLUSTER 'cluster'")
|
||||
conn.query("CREATE TABLE it_cluster.events ON CLUSTER 'cluster' (id UInt64, payload String) ENGINE = ReplicatedMergeTree ORDER BY id")
|
||||
conn.query("CREATE TABLE it_cluster.distributed_events ON CLUSTER 'cluster' AS it_cluster.events ENGINE = Distributed('cluster', 'it_cluster', 'events', cityHash64(id))")
|
||||
|
||||
conn.insert(
|
||||
database="it_cluster",
|
||||
table="distributed_events",
|
||||
column_names=["id", "payload"],
|
||||
data=[[i, f"payload-{i:03d}"] for i in range(TOTAL_ROWS)],
|
||||
)
|
||||
|
||||
distributed_count = int(conn.query("SELECT count() FROM it_cluster.distributed_events").result_rows[0][0])
|
||||
assert distributed_count == TOTAL_ROWS
|
||||
|
||||
local_counts = [int(node_conn.query("SELECT count() FROM it_cluster.events").result_rows[0][0]) for node_conn in clickhouse_node_conns]
|
||||
assert sum(local_counts) == TOTAL_ROWS, f"local counts {local_counts} do not add up to {TOTAL_ROWS}"
|
||||
assert min(local_counts) > 0, f"all rows landed on one shard: {local_counts}"
|
||||
finally:
|
||||
conn.query("DROP DATABASE IF EXISTS it_cluster ON CLUSTER 'cluster' SYNC")
|
||||
@@ -1,48 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.clickhouse import create_clickhouse_cluster
|
||||
from fixtures.keeper import create_clickhouse_keeper
|
||||
|
||||
CLICKHOUSE_VERSION = "25.12.5"
|
||||
|
||||
|
||||
@pytest.fixture(name="keeper", scope="package")
|
||||
def keeper_cluster(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
return create_clickhouse_keeper(
|
||||
tmpfs=tmpfs,
|
||||
network=network,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="keeper_cluster",
|
||||
version=CLICKHOUSE_VERSION,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="clickhouse", scope="package")
|
||||
def clickhouse_cluster(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
keeper: types.TestContainerDocker,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerClickhouse:
|
||||
return create_clickhouse_cluster(
|
||||
tmpfs=tmpfs,
|
||||
network=network,
|
||||
keeper=keeper,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="clickhouse_cluster",
|
||||
shards=2,
|
||||
version=CLICKHOUSE_VERSION,
|
||||
)
|
||||
@@ -1,203 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import clickhouse_connect.driver.client
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metricreduction import assert_spans_shards
|
||||
from fixtures.metrics import (
|
||||
Metrics,
|
||||
MetricsReducedSampleLast60s,
|
||||
MetricsReducedTimeSeries,
|
||||
)
|
||||
from fixtures.querier import aligned_epoch, query_metric_values
|
||||
|
||||
|
||||
def test_query_spanning_rule_activation_combines_raw_and_reduced_data(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
insert_reduced_metrics: Callable[..., None],
|
||||
clickhouse_node_conns: list[clickhouse_connect.driver.client.Client],
|
||||
) -> None:
|
||||
"""Before a reduction rule activates, data lives in the raw tables; after,
|
||||
only the reduced tables have data. A single query spanning the activation
|
||||
time must return one continuous series with no gap and no double counting:
|
||||
32 raw series at 2.0 collapse into 16 groups whose per-minute total is
|
||||
4.0, so the summed value stays 320 per step on both sides. Enough series
|
||||
are seeded that both shards hold data (checked below), so correct totals
|
||||
also prove the queries read every shard."""
|
||||
metric_name = "test_reduction_activation_boundary"
|
||||
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
|
||||
services = [f"svc-{i:02d}" for i in range(16)]
|
||||
|
||||
# first 30 minutes: raw data (2 pods per service, one sample per minute)
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": service, "pod": f"{service}-pod-{pod}"},
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
value=2.0,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for service in services
|
||||
for pod in range(2)
|
||||
for minute in range(30)
|
||||
]
|
||||
)
|
||||
|
||||
# next 30 minutes: reduced data only (one row per service per minute)
|
||||
time_series = [
|
||||
MetricsReducedTimeSeries(
|
||||
metric_name=metric_name,
|
||||
kept_labels={"service": service},
|
||||
timestamp=datetime.fromtimestamp(base_epoch + 30 * 60, tz=UTC),
|
||||
)
|
||||
for service in services
|
||||
]
|
||||
insert_reduced_metrics(
|
||||
time_series,
|
||||
[
|
||||
MetricsReducedSampleLast60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=ts.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + (30 + minute) * 60, tz=UTC),
|
||||
sum_last=4.0,
|
||||
min_value=2.0,
|
||||
max_value=2.0,
|
||||
sum_values=4.0,
|
||||
count_series=2,
|
||||
count_samples=2,
|
||||
)
|
||||
for ts in time_series
|
||||
for minute in range(30)
|
||||
],
|
||||
)
|
||||
|
||||
assert_spans_shards(clickhouse_node_conns, "time_series_v4", metric_name, total=len(services) * 2)
|
||||
assert_spans_shards(clickhouse_node_conns, "time_series_v4_reduced", metric_name, total=len(services))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 3600, "sum", "sum", step_interval=300)
|
||||
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(12)]
|
||||
assert [v["value"] for v in values] == [320.0] * 12
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"space_agg, expected",
|
||||
[
|
||||
("sum", 12.0), # sum_last: 4 + 8
|
||||
("avg", 3.0), # sum(sum_last) / sum(count_series): 12 / 4
|
||||
("min", 1.0), # min(min)
|
||||
("max", 6.0), # max(max)
|
||||
],
|
||||
)
|
||||
def test_aggregations_across_series(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_reduced_metrics: Callable[..., None],
|
||||
space_agg: str,
|
||||
expected: float,
|
||||
) -> None:
|
||||
"""Aggregating across series reads the pre-aggregated reduced columns:
|
||||
sum/avg from sum_last with the count_series weight, min/max from the
|
||||
min/max columns."""
|
||||
metric_name = f"test_reduction_across_series_{space_agg}"
|
||||
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
|
||||
|
||||
groups = [
|
||||
# (service, sum_last, min, max, count_series)
|
||||
("a", 4.0, 1.0, 3.0, 2),
|
||||
("b", 8.0, 2.0, 6.0, 2),
|
||||
]
|
||||
time_series = {
|
||||
service: MetricsReducedTimeSeries(
|
||||
metric_name=metric_name,
|
||||
kept_labels={"service": service},
|
||||
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
|
||||
)
|
||||
for service, _, _, _, _ in groups
|
||||
}
|
||||
insert_reduced_metrics(
|
||||
list(time_series.values()),
|
||||
[
|
||||
MetricsReducedSampleLast60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=time_series[service].fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_last=sum_last,
|
||||
min_value=min_value,
|
||||
max_value=max_value,
|
||||
sum_values=sum_last,
|
||||
count_series=count_series,
|
||||
count_samples=count_series,
|
||||
)
|
||||
for service, sum_last, min_value, max_value, count_series in groups
|
||||
for minute in range(20)
|
||||
],
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 20 * 60, "avg", space_agg, step_interval=300)
|
||||
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
|
||||
assert [v["value"] for v in values] == [expected] * 4
|
||||
|
||||
|
||||
def test_recomputed_minutes_use_only_the_newest_values(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_reduced_metrics: Callable[..., None],
|
||||
) -> None:
|
||||
"""The collector rewrites recent minutes on every refresh, so the same
|
||||
minute exists multiple times with increasing computed_at. Queries must
|
||||
count each minute once, using its newest version: write the same minutes
|
||||
twice with different values and only the second write may show up."""
|
||||
metric_name = "test_reduction_recompute"
|
||||
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
|
||||
|
||||
time_series = [
|
||||
MetricsReducedTimeSeries(
|
||||
metric_name=metric_name,
|
||||
kept_labels={"service": service},
|
||||
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
|
||||
)
|
||||
for service in ("a", "b")
|
||||
]
|
||||
|
||||
def minute_rows(sum_last: float, computed_at_offset_seconds: int) -> list[MetricsReducedSampleLast60s]:
|
||||
return [
|
||||
MetricsReducedSampleLast60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=ts.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_last=sum_last,
|
||||
min_value=sum_last,
|
||||
max_value=sum_last,
|
||||
sum_values=sum_last,
|
||||
count_series=1,
|
||||
count_samples=1,
|
||||
computed_at=datetime.fromtimestamp(base_epoch + minute * 60 + computed_at_offset_seconds, tz=UTC),
|
||||
)
|
||||
for ts in time_series
|
||||
for minute in range(10)
|
||||
]
|
||||
|
||||
# first write says 1.0; a later rewrite of the same minutes says 5.0
|
||||
insert_reduced_metrics(time_series, minute_rows(sum_last=1.0, computed_at_offset_seconds=120))
|
||||
insert_reduced_metrics(time_series, minute_rows(sum_last=5.0, computed_at_offset_seconds=180))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 10 * 60, "sum", "sum", step_interval=300)
|
||||
|
||||
# 2 groups x 5 minutes x 5.0 per step; the 1.0 rows must not contribute
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(2)]
|
||||
assert [v["value"] for v in values] == [50.0] * 2
|
||||
@@ -1,70 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import (
|
||||
MetricsReducedSampleSum60s,
|
||||
MetricsReducedTimeSeries,
|
||||
)
|
||||
from fixtures.querier import aligned_epoch, query_metric_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_agg, expected",
|
||||
[
|
||||
# 2 groups x 5 minutes x 30.0 per 300s step
|
||||
("rate", 1.0), # 300 / 300s
|
||||
("increase", 300.0),
|
||||
],
|
||||
)
|
||||
def test_counter_rate_and_increase(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_reduced_metrics: Callable[..., None],
|
||||
time_agg: str,
|
||||
expected: float,
|
||||
) -> None:
|
||||
metric_name = f"test_reduction_counter_{time_agg}"
|
||||
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
|
||||
|
||||
# monotonic cumulative counter: MetricsReducedTimeSeries mirrors the
|
||||
# collector's temporality rewrite to Delta
|
||||
time_series = [
|
||||
MetricsReducedTimeSeries(
|
||||
metric_name=metric_name,
|
||||
kept_labels={"service": service},
|
||||
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
|
||||
temporality="Cumulative",
|
||||
type_="Sum",
|
||||
is_monotonic=True,
|
||||
)
|
||||
for service in ("a", "b")
|
||||
]
|
||||
assert all(ts.temporality == "Delta" for ts in time_series)
|
||||
|
||||
insert_reduced_metrics(
|
||||
time_series,
|
||||
sum_samples=[
|
||||
MetricsReducedSampleSum60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=ts.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_value=30.0,
|
||||
count_series=2,
|
||||
count_samples=2,
|
||||
temporality="Delta",
|
||||
)
|
||||
for ts in time_series
|
||||
for minute in range(20)
|
||||
],
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 20 * 60, time_agg, "sum", step_interval=300)
|
||||
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
|
||||
assert [v["value"] for v in values] == [expected] * 4
|
||||
@@ -1,70 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metricreduction import build_recent_gauge_data
|
||||
from fixtures.querier import (
|
||||
aligned_epoch,
|
||||
build_builder_query,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
query_metric_values,
|
||||
)
|
||||
|
||||
SERVICES = ("a", "b")
|
||||
PODS_PER_SERVICE = 2
|
||||
MINUTES = 20
|
||||
|
||||
|
||||
def test_recent_queries_return_full_resolution_totals(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_buffer_metrics: Callable[..., None],
|
||||
) -> None:
|
||||
metric_name = "test_reduction_recent_totals"
|
||||
# samples span [now-25m, now-5m); the query window sits inside the last 24h
|
||||
base_epoch = aligned_epoch(timedelta(minutes=25), step_seconds=300)
|
||||
insert_buffer_metrics(*build_recent_gauge_data(metric_name, base_epoch, SERVICES, PODS_PER_SERVICE, MINUTES))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + MINUTES * 60, "sum", "sum", step_interval=300)
|
||||
|
||||
# 4 raw series x 5 samples x 1.0 per step: full raw resolution, and the
|
||||
# reduced series rows must not be counted (their fingerprints match no
|
||||
# samples, and the time-series lookup filters them out)
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
|
||||
assert [v["value"] for v in values] == [float(len(SERVICES) * PODS_PER_SERVICE * 5)] * 4
|
||||
|
||||
|
||||
def test_recent_queries_group_by_full_labels(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_buffer_metrics: Callable[..., None],
|
||||
) -> None:
|
||||
"""Group-by resolves against the raw buffer series rows (full labels), so
|
||||
grouping by the kept label still sees every raw series underneath."""
|
||||
metric_name = "test_reduction_recent_groupby"
|
||||
base_epoch = aligned_epoch(timedelta(minutes=25), step_seconds=300)
|
||||
insert_buffer_metrics(*build_recent_gauge_data(metric_name, base_epoch, SERVICES, PODS_PER_SERVICE, MINUTES))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=base_epoch * 1000,
|
||||
end_ms=(base_epoch + MINUTES * 60) * 1000,
|
||||
queries=[build_builder_query("A", metric_name, "sum", "sum", step_interval=300, group_by=["service"])],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
series_by_service = index_series_by_label(get_all_series(response.json(), "A"), "service")
|
||||
assert set(series_by_service.keys()) == set(SERVICES)
|
||||
for service in SERVICES:
|
||||
values = sorted(series_by_service[service]["values"], key=lambda v: v["timestamp"])
|
||||
# 2 pods x 5 samples x 1.0 per step
|
||||
assert [v["value"] for v in values] == [float(PODS_PER_SERVICE * 5)] * 4
|
||||
@@ -1,99 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import register_admin
|
||||
from fixtures.clickhouse import create_clickhouse_cluster
|
||||
from fixtures.keeper import create_clickhouse_keeper
|
||||
from fixtures.migrator import create_migrator
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
SCHEMA_MIGRATOR_VERSION = "v0.144.6-rc.2"
|
||||
CLICKHOUSE_VERSION = "25.12.5"
|
||||
|
||||
|
||||
@pytest.fixture(name="keeper", scope="package")
|
||||
def keeper_metricreduction(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
return create_clickhouse_keeper(
|
||||
tmpfs=tmpfs,
|
||||
network=network,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="keeper_metricreduction",
|
||||
version=CLICKHOUSE_VERSION,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="clickhouse", scope="package")
|
||||
def clickhouse_metricreduction(
|
||||
tmpfs: Generator[types.LegacyPath, Any],
|
||||
network: Network,
|
||||
keeper: types.TestContainerDocker,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerClickhouse:
|
||||
return create_clickhouse_cluster(
|
||||
tmpfs=tmpfs,
|
||||
network=network,
|
||||
keeper=keeper,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="clickhouse_metricreduction",
|
||||
shards=2,
|
||||
version=CLICKHOUSE_VERSION,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="migrator", scope="package")
|
||||
def migrator_metricreduction(
|
||||
network: Network,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.Operation:
|
||||
return create_migrator(
|
||||
network=network,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="migrator_metricreduction",
|
||||
version=SCHEMA_MIGRATOR_VERSION,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_metricreduction( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_metricreduction",
|
||||
env_overrides={
|
||||
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_ENABLE__METRICS__REDUCTION": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_admin", scope="package")
|
||||
def create_user_admin_metricreduction(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
|
||||
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_metricreduction")
|
||||
Reference in New Issue
Block a user