Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
181878f1df chore: generate api specs 2026-07-08 02:22:15 +05:30
Naman Verma
db54237f77 fix: don't return error in v2 list dashboard api if there is a v1 dashboard 2026-07-08 02:14:55 +05:30
101 changed files with 608 additions and 4934 deletions

View File

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

View File

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

View File

@@ -3148,6 +3148,8 @@ components:
type: string
image:
type: string
legacy:
type: boolean
locked:
type: boolean
name:
@@ -3180,6 +3182,7 @@ components:
- name
- tags
- spec
- legacy
- pinned
type: object
DashboardtypesListedDashboardV2:
@@ -3193,6 +3196,8 @@ components:
type: string
image:
type: string
legacy:
type: boolean
locked:
type: boolean
name:
@@ -3223,6 +3228,7 @@ components:
- name
- tags
- spec
- legacy
type: object
DashboardtypesListedDashboardV2Spec:
properties:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5005,6 +5005,10 @@ export interface DashboardtypesListedDashboardForUserV2DTO {
* @type string
*/
image?: string;
/**
* @type boolean
*/
legacy: boolean;
/**
* @type boolean
*/
@@ -5080,6 +5084,10 @@ export interface DashboardtypesListedDashboardV2DTO {
* @type string
*/
image?: string;
/**
* @type boolean
*/
legacy: boolean;
/**
* @type boolean
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,7 @@ func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *das
return nil, err
}
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags)
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags), nil
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
@@ -90,7 +90,7 @@ func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, user
return nil, err
}
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags)
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags), nil
}
func (module *module) fetchDashboardTags(ctx context.Context, orgID valuer.UUID, dashboardIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, []*tagtypes.Tag, error) {

View File

@@ -17,7 +17,6 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/sync/errgroup"
)
type module struct {
@@ -214,37 +213,23 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Compute per-group active/inactive host counts.
// When host.name is in groupBy, each row = one host, so counts are derived
// directly from activeHostsMap in buildHostRecords (no extra query needed).
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
// using the same filter expression as the main query (including user filters + page groups IN clause).
hostCounts := make(map[string]groupHostStatusCounts)
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
var (
queryResp *qbtypes.QueryRangeResponse
hostCounts = make(map[string]groupHostStatusCounts)
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
if !isHostNameInGroupBy {
g.Go(func() error {
var err error
hostCounts, err = m.getPerGroupHostStatusCounts(gCtx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
if err != nil {
return nil, err
}
}
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
@@ -314,39 +299,23 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
statusCounts, statusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
restartCounts, err := m.getPerGroupPodRestartCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -418,39 +387,23 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCounts map[string]nodeConditionCounts
podPhaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -522,33 +475,18 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -619,41 +557,25 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
podPhaseCountsMap map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -808,33 +730,18 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -911,35 +818,20 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset phase counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -1016,35 +908,20 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job phase counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -1121,35 +998,20 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset phase counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}

View File

@@ -7,8 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
)
const DefaultMaxConcurrentQueries = 8
type SkipResourceFingerprint struct {
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
@@ -39,7 +37,7 @@ func newConfig() factory.Config {
// Default values
CacheTTL: 168 * time.Hour,
FluxInterval: 5 * time.Minute,
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
MaxConcurrentQueries: 4,
SkipResourceFingerprint: SkipResourceFingerprint{
Enabled: false,
Threshold: 100000,

View File

@@ -13,7 +13,6 @@ import (
"github.com/dustin/go-humanize"
"golang.org/x/exp/maps"
"golang.org/x/sync/errgroup"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -36,21 +35,20 @@ var (
)
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
bucketCache BucketCache
liveDataRefresh time.Duration
builderConfig builderConfig
maxConcurrentQueries int
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
bucketCache BucketCache
liveDataRefresh time.Duration
builderConfig builderConfig
}
var _ Querier = (*querier)(nil)
@@ -69,30 +67,25 @@ func New(
bucketCache BucketCache,
flagger flagger.Flagger,
logTraceIDWindowPadding time.Duration,
maxConcurrentQueries int,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
if maxConcurrentQueries <= 0 {
maxConcurrentQueries = DefaultMaxConcurrentQueries
}
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
builderConfig: builderConfig{
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
},
maxConcurrentQueries: maxConcurrentQueries,
}
}
@@ -614,40 +607,30 @@ func (q *querier) run(
return false
}
names := maps.Keys(qs)
slices.Sort(names)
queryResults := make([]*qbtypes.Result, len(names))
// sem limits how many queries run at once for this request. The same
// limit covers the missing-range queries in executeWithCache. sem is held
// only while a query is running, never while waiting for other
// goroutines, so the two levels cannot deadlock.
sem := make(chan struct{}, q.maxConcurrentQueries)
eg, egCtx := errgroup.WithContext(ctx)
for i, name := range names {
query := qs[name]
eg.Go(func() error {
// Skip cache if NoCache is set, or if cache is not available
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
if req.NoCache {
q.logger.DebugContext(egCtx, "NoCache flag set, bypassing cache", slog.String("query", name))
} else {
q.logger.InfoContext(egCtx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
}
sem <- struct{}{}
result, err := query.Execute(egCtx)
<-sem
if err != nil {
return err
}
queryResults[i] = result
return nil
for name, query := range qs {
// Skip cache if NoCache is set, or if cache is not available
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
if req.NoCache {
q.logger.DebugContext(ctx, "NoCache flag set, bypassing cache", slog.String("query", name))
} else {
q.logger.InfoContext(ctx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
}
result, err := q.executeWithCache(egCtx, orgID, query, steps[name], sem)
result, err := query.Execute(ctx)
qbEvent.HasData = qbEvent.HasData || hasData(result)
if err != nil {
return err
return nil, err
}
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL
stats.RowsScanned += result.Stats.RowsScanned
stats.BytesScanned += result.Stats.BytesScanned
stats.DurationMS += result.Stats.DurationMS
} else {
result, err := q.executeWithCache(ctx, orgID, query, steps[name], req.NoCache)
qbEvent.HasData = qbEvent.HasData || hasData(result)
if err != nil {
return nil, err
}
switch v := result.Value.(type) {
case *qbtypes.TimeSeriesData:
@@ -657,23 +640,14 @@ func (q *querier) run(
case *qbtypes.RawData:
v.QueryName = name
}
queryResults[i] = result
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
for i, name := range names {
result := queryResults[i]
qbEvent.HasData = qbEvent.HasData || hasData(result)
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL
stats.RowsScanned += result.Stats.RowsScanned
stats.BytesScanned += result.Stats.BytesScanned
stats.DurationMS += result.Stats.DurationMS
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL
stats.RowsScanned += result.Stats.RowsScanned
stats.BytesScanned += result.Stats.BytesScanned
stats.DurationMS += result.Stats.DurationMS
}
}
gomaps.Copy(results, preseededResults)
@@ -733,9 +707,8 @@ func (q *querier) run(
return resp, nil
}
// executeWithCache executes a query using the bucket cache. sem limits how
// many queries run at once for the whole request.
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, sem chan struct{}) (*qbtypes.Result, error) {
// executeWithCache executes a query using the bucket cache.
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, _ bool) (*qbtypes.Result, error) {
// Get cached data and missing ranges
cachedResult, missingRanges := q.bucketCache.GetMissRanges(ctx, orgID, query, step)
@@ -748,9 +721,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
if cachedResult == nil && len(missingRanges) == 1 {
startMs, endMs := query.Window()
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
sem <- struct{}{}
result, err := query.Execute(ctx)
<-sem
if err != nil {
return nil, err
}
@@ -769,6 +740,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
slog.Int("missing_ranges_count", len(missingRanges)),
slog.Any("ranges", missingRanges))
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for i, timeRange := range missingRanges {
@@ -805,9 +777,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
if err != nil {
// If any query failed, fall back to full execution
q.logger.ErrorContext(ctx, "parallel query execution failed", errors.Attr(err))
sem <- struct{}{}
result, err := query.Execute(ctx)
<-sem
if err != nil {
return nil, err
}

View File

@@ -2,13 +2,11 @@ package querier
import (
"context"
"sync/atomic"
"testing"
"time"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
@@ -56,8 +54,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0, // logTraceIDWindowPadding
0, // maxConcurrentQueries
0,
)
req := &qbtypes.QueryRangeRequest{
@@ -128,8 +125,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0, // logTraceIDWindowPadding
0, // maxConcurrentQueries
0,
)
req := &qbtypes.QueryRangeRequest{
@@ -159,149 +155,3 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
}
type fakeQuery struct {
execute func(ctx context.Context) (*qbtypes.Result, error)
}
func (f *fakeQuery) Fingerprint() string { return "" }
func (f *fakeQuery) Window() (uint64, uint64) { return 0, 0 }
func (f *fakeQuery) Execute(ctx context.Context) (*qbtypes.Result, error) { return f.execute(ctx) }
func chQueryEnvelopes(names []string) []qbtypes.QueryEnvelope {
envelopes := make([]qbtypes.QueryEnvelope, 0, len(names))
for _, name := range names {
envelopes = append(envelopes, qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{Name: name},
})
}
return envelopes
}
func TestRunExecutesQueriesConcurrently(t *testing.T) {
names := []string{"A", "B", "C", "D", "E"}
numQueries := len(names)
q := &querier{
logger: instrumentationtest.New().Logger(),
maxConcurrentQueries: numQueries,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var started atomic.Int32
allStarted := make(chan struct{})
qs := make(map[string]qbtypes.Query, numQueries)
for _, name := range names {
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
if int(started.Add(1)) == numQueries {
close(allStarted)
}
select {
case <-allStarted:
case <-ctx.Done():
return nil, ctx.Err()
}
return &qbtypes.Result{
Type: qbtypes.RequestTypeScalar,
Value: &qbtypes.ScalarData{QueryName: name},
Stats: qbtypes.ExecStats{RowsScanned: 1, BytesScanned: 2, DurationMS: 3},
}, nil
}}
}
req := &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
}
resp, err := q.run(ctx, valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Len(t, resp.Data.Results, numQueries)
assert.Equal(t, uint64(numQueries), resp.Meta.RowsScanned)
assert.Equal(t, uint64(2*numQueries), resp.Meta.BytesScanned)
assert.Equal(t, uint64(3*numQueries), resp.Meta.DurationMS)
}
func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
const limit = 2
names := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
q := &querier{
logger: instrumentationtest.New().Logger(),
maxConcurrentQueries: limit,
}
var running, maxRunning atomic.Int32
qs := make(map[string]qbtypes.Query, len(names))
for _, name := range names {
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
cur := running.Add(1)
defer running.Add(-1)
for {
m := maxRunning.Load()
if cur <= m || maxRunning.CompareAndSwap(m, cur) {
break
}
}
time.Sleep(20 * time.Millisecond)
return &qbtypes.Result{
Type: qbtypes.RequestTypeScalar,
Value: &qbtypes.ScalarData{QueryName: name},
}, nil
}}
}
req := &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
}
resp, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Len(t, resp.Data.Results, len(names))
assert.LessOrEqual(t, maxRunning.Load(), int32(limit), "running queries must not exceed maxConcurrentQueries")
}
func TestRunQueryErrorCancelsSiblings(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
maxConcurrentQueries: 4,
}
bStarted := make(chan struct{})
var bCanceled atomic.Bool
qs := map[string]qbtypes.Query{
// fails once B is running.
"A": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
select {
case <-bStarted:
case <-ctx.Done():
}
return nil, errors.NewInternalf(errors.CodeInternal, "query A failed")
}},
// blocks until its context is canceled by A's failure.
"B": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
close(bStarted)
select {
case <-ctx.Done():
bCanceled.Store(true)
return nil, ctx.Err()
case <-time.After(10 * time.Second):
return nil, errors.NewInternalf(errors.CodeInternal, "query B was never canceled")
}
}},
}
req := &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes([]string{"A", "B"})},
}
_, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
require.ErrorContains(t, err, "query A failed")
assert.True(t, bCanceled.Load(), "query B should be canceled once query A fails")
}

View File

@@ -193,6 +193,5 @@ func newProvider(
bucketCache,
flagger,
cfg.LogTraceIDWindowPadding,
cfg.MaxConcurrentQueries,
), nil
}

View File

@@ -56,7 +56,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // bucketCache
flagger,
0,
0, // maxConcurrentQueries (0 means default)
), metadataStore
}
@@ -111,7 +110,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // bucketCache
fl,
5*time.Minute, // logTraceIDWindowPadding
0, // maxConcurrentQueries (0 means default)
)
}
@@ -160,6 +158,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
)
}

View File

@@ -64,6 +64,13 @@ type (
ListableDashboard []*GettableDashboard
)
// readString reads a string field from the untyped data blob, yielding "" when
// the key is absent, null, or not a string.
func (d StorableDashboardData) readString(key string) string {
s, _ := d[key].(string)
return s
}
func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard, error) {
dashboardID, err := valuer.NewUUID(dashboard.ID)
if err != nil {

View File

@@ -122,6 +122,10 @@ type listedDashboardV2 struct {
Image string `json:"image,omitempty"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Spec listedDashboardV2Spec `json:"spec" required:"true"`
// Legacy marks a dashboard whose stored data is not yet in the v2 (perses)
// schema. Such rows are extracted best-effort from the v1 shape so the list
// still surfaces them; callers should route them to the legacy view.
Legacy bool `json:"legacy" required:"true"`
}
type listedDashboardV2Spec struct {
@@ -144,6 +148,41 @@ func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
}
}
// newListedDashboardForList builds the list view for a single dashboard. A row
// whose stored data is not in the v2 (perses) schema is extracted best-effort
// from the v1 shape and flagged Legacy, so one legacy or malformed dashboard
// can't fail the whole list.
func newListedDashboardForList(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
if v2, err := storable.ToDashboardV2(tags); err == nil {
return newListedDashboardV2(v2)
}
return newLegacyListedDashboardV2(storable, tags)
}
// newLegacyListedDashboardV2 pulls the display-relevant fields out of a v1
// dashboard blob. Column-backed fields (name, source, timestamps, …) come
// straight off the row; title/description/image/version are read leniently from
// the untyped v1 data, defaulting to zero when absent or of the wrong type.
func newLegacyListedDashboardV2(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
return &listedDashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
SchemaVersion: storable.Data.readString("version"),
Name: storable.Name,
Image: storable.Data.readString("image"),
Tags: tagtypes.NewGettableTagsFromTags(tags),
Spec: listedDashboardV2Spec{Display: Display{
Name: storable.Data.readString("title"),
Description: storable.Data.readString("description"),
}},
Legacy: true,
}
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
@@ -151,21 +190,17 @@ type ListableDashboardV2 struct {
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardV2 {
items := make([]*listedDashboardV2, len(dashboards))
for i, d := range dashboards {
v2, err := d.ToDashboardV2(tagsByEntity[d.ID])
if err != nil {
return nil, err
}
items[i] = newListedDashboardV2(v2)
items[i] = newListedDashboardForList(d, tagsByEntity[d.ID])
}
return &ListableDashboardV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
}
// listedDashboardForUserV2 is a listed dashboard plus the calling user's pin
@@ -190,15 +225,11 @@ type StorableDashboardWithPinInfo struct {
Pinned bool
}
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardForUserV2, error) {
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardForUserV2 {
items := make([]*listedDashboardForUserV2, len(rows))
for i, r := range rows {
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
items[i] = &listedDashboardForUserV2{
listedDashboardV2: *newListedDashboardV2(v2),
listedDashboardV2: *newListedDashboardForList(r.Dashboard, tagsByEntity[r.Dashboard.ID]),
Pinned: r.Pinned,
}
}
@@ -207,5 +238,5 @@ func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total i
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
}

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