mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-14 17:00:37 +01:00
Compare commits
1 Commits
refactor/v
...
tvats-impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e315b21390 |
@@ -487,11 +487,8 @@
|
||||
// Simplifies boolean returns
|
||||
"sonarjs/prefer-while": "error",
|
||||
// Suggests while loops over for loops
|
||||
"sonarjs/elseif-without-else": "off",
|
||||
"sonarjs/elseif-without-else": "off"
|
||||
// Requires final else in if-else-if chains (was disabled)
|
||||
"signoz/no-conditional-text-nodes-with-siblings": "warn",
|
||||
// Vendored from eslint-plugin-react-google-translate
|
||||
"signoz/no-return-text-nodes": "warn"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"src/parser/*.ts",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"lint:generated": "oxlint ./src/api/generated --fix",
|
||||
"lint:fix": "oxlint ./src --fix",
|
||||
"lint:styles": "stylelint \"src/**/*.scss\"",
|
||||
"test:plugins": "node --test \"plugins/__tests__/*.test.mjs\"",
|
||||
"jest": "jest",
|
||||
"jest:coverage": "jest --coverage",
|
||||
"jest:watch": "jest --watch",
|
||||
@@ -126,7 +125,6 @@
|
||||
"rrule": "2.8.1",
|
||||
"styled-components": "^5.3.11",
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"translation-resilience": "^0.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"uplot": "1.6.31",
|
||||
"uuid": "14.0.1",
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Plugin rule tests
|
||||
|
||||
Tests for the custom oxlint rules in `plugins/rules/`.
|
||||
|
||||
```bash
|
||||
pnpm test:plugins
|
||||
```
|
||||
|
||||
Runs on `node --test` rather than jest. The jest config is built for application
|
||||
code — jsdom, ts-jest ESM transforms, a large `transformIgnorePatterns` wall —
|
||||
and none of it applies to a suite whose only job is to shell out to the linter.
|
||||
|
||||
## Why it drives the real binary
|
||||
|
||||
Each case is written to a temp file and linted by the actual `oxlint` binary,
|
||||
with every builtin category switched off so the only diagnostics that can appear
|
||||
belong to the rule under test. Assertions therefore describe what CI enforces.
|
||||
|
||||
The alternative — walking the AST in-process — would need a stand-in for
|
||||
oxlint's JS plugin AST. That AST is ESTree-shaped but not ESTree, and it carries
|
||||
no type information, so a stand-in would drift from the runtime it claims to
|
||||
model and the tests would certify behaviour that never happens.
|
||||
|
||||
All cases in a suite share one `oxlint` invocation and are mapped back by
|
||||
filename. Per-case spawning costs roughly 80ms each; batching keeps both suites
|
||||
together at around 250ms.
|
||||
|
||||
## Adding a suite
|
||||
|
||||
```js
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-navigator-clipboard',
|
||||
valid: ['const x = 1;'],
|
||||
invalid: [
|
||||
{
|
||||
code: 'navigator.clipboard.writeText("x");',
|
||||
errors: [{ message: 'useCopyToClipboard', line: 1, column: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
`ruleTester` must be awaited at the top level — it loads the plugin and runs
|
||||
`oxlint` before declaring the tests.
|
||||
|
||||
- `rule` — the key the plugin exports it under. `plugin` defaults to
|
||||
`plugins/signoz.mjs`; pass a path relative to `frontend/` for another plugin.
|
||||
- Cases are `.tsx` unless a `filename` gives another extension.
|
||||
- `errors` takes a count or an array. Each entry may assert `message` (substring
|
||||
or `RegExp`), `line` and `column`; omitted fields are not checked.
|
||||
- `name` labels the case in the output and defaults to its first line of code.
|
||||
- `output` asserts the source after suggestions are applied — see below.
|
||||
- `todo` marks a case as a known defect — see below.
|
||||
|
||||
## Suggestions
|
||||
|
||||
Both Google Translate rules attach their wrap as a *suggestion*, not a fix, so
|
||||
`--fix` leaves the code alone and `--fix-suggestions` applies it. The wrap is
|
||||
`<span className="translate-safe">`, and `.translate-safe` is `display: contents`
|
||||
in `src/styles.scss`: React owns an element that absorbs Translate's `<font>`
|
||||
swap, while the box tree stays as it was, so a flex or grid parent still sees one
|
||||
contiguous text run rather than a new item with its own `gap`.
|
||||
|
||||
It stays a suggestion because the element is still a DOM child even with no box:
|
||||
`> *`, `:nth-child` and sibling selectors still count it, and a component that
|
||||
inspects its children — `React.Children.map`, antd `Tooltip`, `Space` — sees an
|
||||
element where a string used to be. That is what oxlint means by "May change
|
||||
program behavior" in `--fix-suggestions --help`.
|
||||
|
||||
An invalid case carrying `output` is linted twice: once for diagnostics, and
|
||||
once with `--fix-suggestions` over an untouched copy of the same files. The
|
||||
second run costs one extra `oxlint` spawn per suite and only happens when at
|
||||
least one case asks for it.
|
||||
|
||||
```js
|
||||
{
|
||||
code: "export const A = () => <div>{f ? 'a' : 'b'}<b/></div>;",
|
||||
errors: 2,
|
||||
output:
|
||||
'export const A = () => <div>{f ? <span className="translate-safe">a</span> : <span className="translate-safe">b</span>}<b/></div>;',
|
||||
}
|
||||
```
|
||||
|
||||
Suggestions do not reformat, so a real run is `oxlint --fix-suggestions` then
|
||||
`oxfmt`.
|
||||
|
||||
## Known defects
|
||||
|
||||
A case carrying `todo` asserts what the rule *should* do. It still runs, but a
|
||||
failure is reported as a todo rather than failing the suite, so a bug can be
|
||||
pinned as an executable spec instead of prose. Fixing the rule turns the todo
|
||||
green; deleting the flag then makes it a regression guard.
|
||||
|
||||
Cases are prefixed `FP:` where the rule reports something it should not, `GAP:`
|
||||
where it misses something it should catch, and `TYPE-AWARE:` where the miss is
|
||||
only fixable once the linter can resolve types. Everything without a flag is a
|
||||
characterisation test recording current behaviour.
|
||||
|
||||
The current todos:
|
||||
|
||||
**Gaps — constructs the rules never visit.** `isProblematicConditional` requires
|
||||
a `JSXElement` parent, so a conditional inside a fragment is never inspected even
|
||||
though the failure does not care about the parent's kind. `no-return-text-nodes`
|
||||
listens only for `FunctionDeclaration` and reads the name off `node.id`, so
|
||||
arrow-function components and anonymous default exports are invisible — this
|
||||
codebase writes components as arrow functions, which is why that rule reports
|
||||
nothing across `src`.
|
||||
|
||||
Class components are left out deliberately rather than pinned as a gap: there
|
||||
are none in `src`.
|
||||
|
||||
**Type-aware gaps.** Upstream resolves branch types through
|
||||
`@typescript-eslint/utils` and reports anything typed `string` or `number`.
|
||||
oxlint's JS plugin runtime exposes no type information — `sourceCode.parserServices`
|
||||
is always `{}` — so those code paths were removed rather than left dormant. The
|
||||
`TYPE-AWARE:` todos record what they used to catch, and become the acceptance
|
||||
criteria if oxlint ever hands types to JS plugins.
|
||||
|
||||
## Not a defect
|
||||
|
||||
Without types, `no-conditional-text-nodes-with-siblings` falls back to a callee
|
||||
allowlist (`t`, `formatMessage`, `toString`, `toLocaleString`). Cases around
|
||||
that allowlist pin its edges; widening it is the supported way to catch more
|
||||
call expressions.
|
||||
|
||||
Both branches of a ternary are reported separately, so one fix can clear two
|
||||
diagnostics. That inflates the count but every reported node is genuinely a text
|
||||
node, so the cases assert both.
|
||||
@@ -1,302 +0,0 @@
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
const CONDITIONAL = 'Conditionally rendered text nodes with siblings';
|
||||
const PRECEDED = 'Text nodes which are preceded by a conditional expression';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-conditional-text-nodes-with-siblings',
|
||||
valid: [
|
||||
{
|
||||
name: 'conditional text node without siblings',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'boolean branches are not text',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? true : false}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'null branches are not text',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? null : null}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'element branches are already wrapped',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <b>y</b> : <i>n</i>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'text node before the conditional is safe',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\tleading text\n\t\t{flag && <b>y</b>}\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'member expression on the condition side is not rendered',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{obj.name && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'binary comparison on the condition side is not rendered',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{obj.name === 'x' && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
},
|
||||
// An empty string renders no text node at all, so there is nothing for
|
||||
// Google Translate to wrap and nothing for React to lose. Reporting it used
|
||||
// to be the rule's most common false positive.
|
||||
{
|
||||
name: 'element branch with an empty-string fallback',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? <b>Free Trial</b> : ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'both branches empty',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? '' : ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'logical and with an empty-string right-hand side',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag && ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
|
||||
// A template literal is checked the same way as the quoted form, so `{' '}`
|
||||
// and ``{` `}`` agree.
|
||||
{
|
||||
name: 'empty template literal branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? `` : ''}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only template literal branch is skipped',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? ` ` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'string literal branches with an element sibling',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 19 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">yes</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'logical and with a string right-hand side',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag && 'yes'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 12 }],
|
||||
},
|
||||
{
|
||||
name: 'numeric literals render as text',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? 1 : 2}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 15 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{1}</span> : <span className="translate-safe">{2}</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'template literal branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? `yes ${n}` : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 24 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{`yes ${n}`}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'member expression branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? obj.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 22 },
|
||||
],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{obj.name}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'a string needing escapes stays inside braces',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? "it\'s" : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{"it\'s"}</span> : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'optional chaining branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? obj?.deep?.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 29 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'nested ternary reports every text branch',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{a ? (b ? 'x' : 'y') : 'z'}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 13 },
|
||||
{ message: CONDITIONAL, line: 3, column: 19 },
|
||||
{ message: CONDITIONAL, line: 3, column: 26 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'static text following a conditional',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\ttrailing text\n\t</div>\n);',
|
||||
errors: [{ message: PRECEDED, line: 3, column: 21 }],
|
||||
// Only the visible run is wrapped; the surrounding newlines and tabs are
|
||||
// formatting and must stay outside the element.
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">trailing text</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'conditional text plus trailing static text reports both kinds',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? 'a' : 'b'}\n\t\tliteral tail\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 17 },
|
||||
{ message: PRECEDED, line: 3, column: 21 },
|
||||
],
|
||||
},
|
||||
|
||||
// The callee allowlist below is the untyped fallback. Without type
|
||||
// information the rule can only recognise known string-returning helpers,
|
||||
// so `t()` and `formatMessage()` are reported while an arbitrary call is
|
||||
// not. These cases pin that boundary.
|
||||
{
|
||||
name: 't() branch is reported via the callee allowlist',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? t('key') : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'formatMessage() branch is reported via the callee allowlist',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? formatMessage({id:'k'}) : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 37 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'arbitrary call is not recognised, only the literal branch reports',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 25 }],
|
||||
},
|
||||
{
|
||||
name: 'bare identifier is not recognised, only the literal branch reports',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 18 }],
|
||||
},
|
||||
{
|
||||
name: 'toString() branch is reported',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 28 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'toLocaleString() branch is reported',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toLocaleString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 34 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 't() in its own container following a conditional',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{t('key')}\n\t</div>\n);",
|
||||
errors: [{ message: PRECEDED, line: 4, column: 4 }],
|
||||
},
|
||||
{
|
||||
name: 'toString() in its own container following a conditional',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{val.toString()}\n\t</div>\n);',
|
||||
errors: [{ message: PRECEDED, line: 4, column: 4 }],
|
||||
// The whole container is replaced, so the result is not `{<span>{…}</span>}`.
|
||||
output:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">{val.toString()}</span>\n\t</div>\n);',
|
||||
},
|
||||
{
|
||||
name: 'whitespace-only string branch is skipped, the other branch reports',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? ' ' : 'x'}\n\t\t<span>s</span>\n\t</div>\n);",
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 17 }],
|
||||
},
|
||||
{
|
||||
name: 'template literal holding an expression is not blank',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag ? `${n}` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
|
||||
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
|
||||
},
|
||||
|
||||
// Upstream resolves branch types through `@typescript-eslint/utils` and
|
||||
// reports anything typed `string` or `number`. oxlint's JS plugin runtime
|
||||
// exposes no type information, so those paths were dropped and only the
|
||||
// callee allowlist remains. Kept as todos: if oxlint ever hands types to JS
|
||||
// plugins these become the acceptance criteria.
|
||||
{
|
||||
todo: 'needs type information to know the call returns a string',
|
||||
name: 'TYPE-AWARE: call returning a string',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
todo: 'needs type information to know the identifier is a string',
|
||||
name: 'TYPE-AWARE: identifier holding a string',
|
||||
code:
|
||||
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
todo: 'needs type information to know the identifier is a string',
|
||||
name: 'TYPE-AWARE: string identifier following a conditional',
|
||||
code:
|
||||
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{label}\n\t</div>\n);',
|
||||
errors: [{ message: PRECEDED, line: 4, column: 4 }],
|
||||
},
|
||||
|
||||
// `isChildOfJSXElement` matches only `JSXElement`, so a fragment parent is
|
||||
// never inspected. The Google Translate failure does not care whether the
|
||||
// parent is an element or a fragment.
|
||||
{
|
||||
todo: 'fragment parents are never inspected',
|
||||
name: 'GAP: conditional text with a sibling inside a fragment',
|
||||
code:
|
||||
"export const A = () => (\n\t<>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</>\n);",
|
||||
errors: [
|
||||
{ message: CONDITIONAL, line: 3, column: 11 },
|
||||
{ message: CONDITIONAL, line: 3, column: 19 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
const RETURNS_TEXT = 'React components should avoid returning text nodes';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-return-text-nodes',
|
||||
valid: [
|
||||
{
|
||||
name: 'lowercase function is not a component',
|
||||
code: "export function foo() {\n\treturn 'text';\n}",
|
||||
},
|
||||
{
|
||||
name: 'returning JSX',
|
||||
code: 'export function Foo() {\n\treturn <div>hi</div>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'returning null',
|
||||
code: 'export function Foo() {\n\treturn null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'returning boolean',
|
||||
code: 'export function Foo() {\n\treturn true;\n}',
|
||||
},
|
||||
{ name: 'bare return', code: 'export function Foo() {\n\treturn;\n}' },
|
||||
{
|
||||
name: 'returning a variable is not a literal',
|
||||
code: "export function Foo() {\n\tconst s = 'x';\n\treturn s;\n}",
|
||||
},
|
||||
{
|
||||
name: 'lowercase nested function inside a component',
|
||||
code:
|
||||
"export function Foo() {\n\tfunction helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
},
|
||||
|
||||
{
|
||||
// The repo has no class components, so this is out of scope rather than
|
||||
// a gap worth closing.
|
||||
name: 'class method',
|
||||
code: "export class Foo {\n\trender() {\n\t\treturn 'text';\n\t}\n}",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'string literal',
|
||||
code: "export function Foo() {\n\treturn 'text';\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
output:
|
||||
'export function Foo() {\n\treturn <span className="translate-safe">{\'text\'}</span>;\n}',
|
||||
},
|
||||
{
|
||||
// JSX does not parse in a `.ts` file, so no suggestion is offered there.
|
||||
name: 'string literal in a non-JSX file',
|
||||
filename: 'case.ts',
|
||||
code: "export function Foo() {\n\treturn 'text';\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
output: "export function Foo() {\n\treturn 'text';\n}",
|
||||
},
|
||||
{
|
||||
name: 'numeric literal',
|
||||
code: 'export function Foo() {\n\treturn 42;\n}',
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
{
|
||||
name: 'template literal',
|
||||
code: 'export function Foo() {\n\treturn `text ${x}`;\n}',
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
{
|
||||
name: 'inside an if consequent',
|
||||
code:
|
||||
"export function Foo() {\n\tif (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside an else block',
|
||||
code:
|
||||
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else {\n\t\treturn 'x';\n\t}\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside an else-if chain',
|
||||
code:
|
||||
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else if (b) {\n\t\treturn 'x';\n\t}\n\treturn null;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a switch case',
|
||||
code:
|
||||
"export function Foo() {\n\tswitch (a) {\n\t\tcase 1:\n\t\t\treturn 'x';\n\t\tdefault:\n\t\t\treturn <div/>;\n\t}\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 4, column: 4 }],
|
||||
},
|
||||
{
|
||||
name: 'inside try, catch and finally',
|
||||
code:
|
||||
"export function Foo() {\n\ttry {\n\t\treturn 'a';\n\t} catch {\n\t\treturn 'b';\n\t} finally {\n\t\treturn 'c';\n\t}\n}",
|
||||
errors: [
|
||||
{ message: RETURNS_TEXT, line: 3, column: 3 },
|
||||
{ message: RETURNS_TEXT, line: 5, column: 3 },
|
||||
{ message: RETURNS_TEXT, line: 7, column: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'inside a for loop',
|
||||
code:
|
||||
"export function Foo() {\n\tfor (let i = 0; i < 3; i++) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a for-of loop',
|
||||
code:
|
||||
"export function Foo() {\n\tfor (const i of list) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a for-in loop',
|
||||
code:
|
||||
"export function Foo() {\n\tfor (const k in obj) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a while loop',
|
||||
code:
|
||||
"export function Foo() {\n\twhile (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'inside a do-while loop',
|
||||
code: "export function Foo() {\n\tdo {\n\t\treturn 'x';\n\t} while (a);\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
{
|
||||
name: 'capitalised nested function is treated as a component',
|
||||
code:
|
||||
"export function Foo() {\n\tfunction Helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
|
||||
},
|
||||
|
||||
// The rule listens only for `FunctionDeclaration` and reads the component
|
||||
// name off `node.id`. Everything below returns a text node from something
|
||||
// React renders as a component, and none of it is reported. This codebase
|
||||
// writes components as arrow functions, which is why the rule currently
|
||||
// finds nothing in `src`.
|
||||
{
|
||||
todo: 'arrow function components are never visited',
|
||||
name: 'GAP: arrow component with an expression body',
|
||||
code: "export const Foo = () => 'text';",
|
||||
errors: 1,
|
||||
},
|
||||
{
|
||||
todo: 'arrow function components are never visited',
|
||||
name: 'GAP: arrow component with a block body',
|
||||
code: "export const Foo = () => {\n\treturn 'text';\n};",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
{
|
||||
todo: 'anonymous declarations have no node.id to read a name from',
|
||||
name: 'GAP: anonymous default-exported component',
|
||||
code: "export default function () {\n\treturn 'text';\n}",
|
||||
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* Test harness for oxlint JS plugins.
|
||||
*
|
||||
* Rules are exercised through the real `oxlint` binary rather than a hand-rolled
|
||||
* AST walker, so what the tests assert is exactly what CI enforces. oxlint's JS
|
||||
* plugin AST is close to ESTree but not identical, and it exposes no type
|
||||
* information, so any in-process fake would drift from the real runtime.
|
||||
*
|
||||
* All cases in a suite are written to one temp directory and linted in a single
|
||||
* oxlint invocation, then mapped back by filename. Spawning per case costs ~80ms
|
||||
* each; batching keeps a full suite under a second.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const FRONTEND_DIR = path.resolve(fileURLToPath(import.meta.url), '../../..');
|
||||
const OXLINT_BIN = path.join(FRONTEND_DIR, 'node_modules/.bin/oxlint');
|
||||
|
||||
// oxlint enables its default categories unless every one is switched off, and a
|
||||
// stray builtin diagnostic would be indistinguishable from the rule under test.
|
||||
const CATEGORIES_OFF = {
|
||||
correctness: 'off',
|
||||
suspicious: 'off',
|
||||
pedantic: 'off',
|
||||
perf: 'off',
|
||||
style: 'off',
|
||||
restriction: 'off',
|
||||
nursery: 'off',
|
||||
};
|
||||
|
||||
function normaliseCase(entry, index) {
|
||||
const testCase = typeof entry === 'string' ? { code: entry } : entry;
|
||||
const extension = testCase.filename
|
||||
? path.extname(testCase.filename).slice(1)
|
||||
: 'tsx';
|
||||
return {
|
||||
...testCase,
|
||||
index,
|
||||
basename: `case-${String(index).padStart(3, '0')}.${extension}`,
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticFilename(diagnostic) {
|
||||
const raw = diagnostic.filename ?? '';
|
||||
const asPath = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
|
||||
return path.basename(asPath);
|
||||
}
|
||||
|
||||
function toError(diagnostic) {
|
||||
const span = diagnostic.labels?.[0]?.span;
|
||||
return {
|
||||
message: diagnostic.message,
|
||||
line: span?.line,
|
||||
column: span?.column,
|
||||
};
|
||||
}
|
||||
|
||||
function runOxlint(dir, configPath, extraArgs = []) {
|
||||
const args = ['--config', configPath, '--format', 'json', ...extraArgs, '.'];
|
||||
try {
|
||||
return execFileSync(OXLINT_BIN, args, {
|
||||
cwd: dir,
|
||||
encoding: 'utf8',
|
||||
// A rule that reports on every case can produce a lot of output.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
// oxlint exits non-zero whenever it reports a diagnostic, which is the
|
||||
// expected outcome for every `invalid` case.
|
||||
if (typeof error.stdout === 'string' && error.stdout.trim() !== '') {
|
||||
return error.stdout;
|
||||
}
|
||||
throw new Error(`oxlint failed to run:\n${error.stderr || error.message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function writeSuite(dir, cases, { pluginPath, ruleId }) {
|
||||
for (const testCase of cases) {
|
||||
const target = path.join(dir, testCase.basename);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, testCase.code);
|
||||
}
|
||||
|
||||
const configPath = path.join(dir, '.oxlintrc.json');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
jsPlugins: [pluginPath],
|
||||
categories: CATEGORIES_OFF,
|
||||
rules: { [ruleId]: 'error' },
|
||||
}),
|
||||
);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lints every case in one pass, and applies suggestions in a second pass over an
|
||||
* untouched copy when any case declares `output`.
|
||||
*
|
||||
* @returns {{errors: Map<string, object[]>, outputs: Map<string, string>}}
|
||||
*/
|
||||
function lintCases(cases, options) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'oxlint-rule-tester-'));
|
||||
try {
|
||||
const lintDir = path.join(root, 'lint');
|
||||
mkdirSync(lintDir);
|
||||
const report = JSON.parse(
|
||||
runOxlint(lintDir, writeSuite(lintDir, cases, options)),
|
||||
);
|
||||
|
||||
const errors = new Map(cases.map((testCase) => [testCase.basename, []]));
|
||||
for (const diagnostic of report.diagnostics ?? []) {
|
||||
const bucket = errors.get(diagnosticFilename(diagnostic));
|
||||
// oxlint reports config-level problems without a filename; surfacing
|
||||
// them as a suite failure beats silently testing nothing.
|
||||
if (!bucket) {
|
||||
throw new Error(`Unexpected diagnostic: ${diagnostic.message}`);
|
||||
}
|
||||
bucket.push(toError(diagnostic));
|
||||
}
|
||||
|
||||
const outputs = new Map();
|
||||
if (cases.some((testCase) => testCase.output !== undefined)) {
|
||||
const fixDir = path.join(root, 'fix');
|
||||
mkdirSync(fixDir);
|
||||
runOxlint(fixDir, writeSuite(fixDir, cases, options), ['--fix-suggestions']);
|
||||
for (const testCase of cases) {
|
||||
outputs.set(
|
||||
testCase.basename,
|
||||
readFileSync(path.join(fixDir, testCase.basename), 'utf8'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, outputs };
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function assertMessage(actual, expected, label) {
|
||||
if (expected instanceof RegExp) {
|
||||
assert.match(actual, expected, label);
|
||||
} else {
|
||||
assert.ok(
|
||||
actual.includes(expected),
|
||||
`${label}\n expected message to contain: ${expected}\n actual: ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertErrors(actual, expected, code) {
|
||||
const context = `\n--- code ---\n${code}\n--- reported ---\n${JSON.stringify(actual, null, 2)}`;
|
||||
|
||||
if (typeof expected === 'number') {
|
||||
assert.equal(actual.length, expected, `error count${context}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(actual.length, expected.length, `error count${context}`);
|
||||
expected.forEach((want, i) => {
|
||||
const got = actual[i];
|
||||
if (want.message !== undefined) {
|
||||
assertMessage(got.message, want.message, `error[${i}] message${context}`);
|
||||
}
|
||||
if (want.line !== undefined) {
|
||||
assert.equal(got.line, want.line, `error[${i}] line${context}`);
|
||||
}
|
||||
if (want.column !== undefined) {
|
||||
assert.equal(got.column, want.column, `error[${i}] column${context}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a suite for one rule.
|
||||
*
|
||||
* A case carrying `todo` asserts the behaviour the rule *should* have. It still
|
||||
* runs, but a failure is reported as a todo instead of failing the suite, so a
|
||||
* known bug can be pinned as an executable spec. Delete the flag once the rule
|
||||
* is fixed and the case starts guarding the fix.
|
||||
*
|
||||
* An invalid case carrying `output` also asserts the source after
|
||||
* `--fix-suggestions` has been applied.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.rule - rule name as exported by the plugin
|
||||
* @param {string} [options.plugin] - path to the plugin, relative to `frontend/`
|
||||
* @param {Array<string | {code: string, name?: string, filename?: string, todo?: string}>} options.valid
|
||||
* @param {Array<{code: string, name?: string, filename?: string, todo?: string, output?: string, errors: number | Array<{message?: string | RegExp, line?: number, column?: number}>}>} options.invalid
|
||||
*/
|
||||
export async function ruleTester({
|
||||
rule,
|
||||
plugin = 'plugins/signoz.mjs',
|
||||
valid = [],
|
||||
invalid = [],
|
||||
}) {
|
||||
const pluginPath = path.join(FRONTEND_DIR, plugin);
|
||||
const { default: pluginModule } = await import(pathToFileURL(pluginPath));
|
||||
|
||||
assert.ok(
|
||||
pluginModule.rules?.[rule],
|
||||
`plugin ${plugin} does not export a rule named "${rule}"`,
|
||||
);
|
||||
|
||||
const ruleId = `${pluginModule.meta.name}/${rule}`;
|
||||
const validCases = valid.map(normaliseCase);
|
||||
const invalidCases = invalid.map((entry, i) =>
|
||||
normaliseCase(entry, valid.length + i),
|
||||
);
|
||||
const { errors, outputs } = lintCases([...validCases, ...invalidCases], {
|
||||
pluginPath,
|
||||
ruleId,
|
||||
});
|
||||
|
||||
const declare = (t, testCase, expected) => {
|
||||
const label = testCase.name ?? testCase.code.trim().split('\n')[0];
|
||||
return t.test(label, { todo: testCase.todo }, () => {
|
||||
assertErrors(errors.get(testCase.basename), expected, testCase.code);
|
||||
if (testCase.output !== undefined) {
|
||||
assert.equal(
|
||||
outputs.get(testCase.basename),
|
||||
testCase.output,
|
||||
`suggestion output\n--- code ---\n${testCase.code}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
test(ruleId, async (t) => {
|
||||
await t.test('valid', async (t) => {
|
||||
for (const testCase of validCases) {
|
||||
await declare(t, testCase, 0);
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('invalid', async (t) => {
|
||||
for (const testCase of invalidCases) {
|
||||
await declare(t, testCase, testCase.errors);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* Rule: no-conditional-text-nodes-with-siblings
|
||||
*
|
||||
* Conditionally rendered text nodes with siblings should be wrapped in an
|
||||
* element (for example a `<span>`), otherwise Google Translate causes a browser
|
||||
* error. Translate replaces the text node with a `<font>` wrapper, React still
|
||||
* holds a reference to the original node, and the next render throws on
|
||||
* `removeChild`.
|
||||
*
|
||||
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
|
||||
* (v1.0.4). The upstream rule resolves branch types through
|
||||
* `@typescript-eslint/utils`; oxlint's JS plugin runtime exposes no type
|
||||
* information, so those paths are dropped and call expressions are matched
|
||||
* against the allowlist below instead.
|
||||
*/
|
||||
|
||||
// Calls known to render as text. Without types this is the only way to
|
||||
// recognise a string-returning call; widen it to catch more helpers.
|
||||
const TEXT_RETURNING_CALLEES = new Set(['formatMessage', 't']);
|
||||
const STRINGIFY_METHODS = new Set(['toString', 'toLocaleString']);
|
||||
|
||||
function calleeName(node) {
|
||||
return node.type === 'Identifier' ? node.name : null;
|
||||
}
|
||||
|
||||
function isTextReturningCall(node) {
|
||||
const { callee } = node;
|
||||
|
||||
if (TEXT_RETURNING_CALLEES.has(calleeName(callee))) {
|
||||
return node.arguments.length > 0;
|
||||
}
|
||||
|
||||
if (callee.type === 'MemberExpression' && !callee.computed) {
|
||||
return STRINGIFY_METHODS.has(calleeName(callee.property));
|
||||
}
|
||||
|
||||
return STRINGIFY_METHODS.has(calleeName(callee));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the node renders no visible text. An empty or whitespace-only value
|
||||
* produces no DOM text node, so Google Translate has nothing to wrap and React
|
||||
* nothing to lose.
|
||||
*/
|
||||
function isBlankText(node) {
|
||||
if (node.type === 'Literal' || node.type === 'JSXText') {
|
||||
return typeof node.value === 'string' && node.value.trim() === '';
|
||||
}
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
return (
|
||||
node.expressions.length === 0 &&
|
||||
node.quasis.every((quasi) => (quasi.value.cooked ?? '').trim() === '')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isConditionallyRendered(node) {
|
||||
const parent = node.parent;
|
||||
return (
|
||||
parent?.type === 'ConditionalExpression' ||
|
||||
parent?.type === 'LogicalExpression'
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderedConditional(node) {
|
||||
return (
|
||||
node.type === 'JSXExpressionContainer' &&
|
||||
(node.expression?.type === 'ConditionalExpression' ||
|
||||
node.expression?.type === 'LogicalExpression')
|
||||
);
|
||||
}
|
||||
|
||||
/** Children that produce output, i.e. everything but formatting whitespace. */
|
||||
function renderedChildren(node) {
|
||||
const children = node?.children;
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
return children.filter((child) => !isBlankText(child));
|
||||
}
|
||||
|
||||
/** True when `node` is a JSX child rendered alongside at least one other child. */
|
||||
function hasSiblings(node) {
|
||||
if (!(node?.parent?.children?.length > 1)) {
|
||||
return false;
|
||||
}
|
||||
return renderedChildren(node.parent).some((child) => child !== node);
|
||||
}
|
||||
|
||||
function isPrecededByConditional(node) {
|
||||
const children = renderedChildren(node?.parent);
|
||||
if (!children) {
|
||||
return false;
|
||||
}
|
||||
return children.some(
|
||||
(child) => child.start < node.start && isRenderedConditional(child),
|
||||
);
|
||||
}
|
||||
|
||||
/** Walk out of nested conditionals so nested branches report against the outer container. */
|
||||
function getOutermostConditional(node) {
|
||||
let current = node;
|
||||
while (isConditionallyRendered(current)) {
|
||||
current = current.parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** True when `node` is a conditional branch rendered directly beside other JSX children. */
|
||||
function isProblematicConditional(node) {
|
||||
if (!isConditionallyRendered(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const container = getOutermostConditional(node);
|
||||
return (
|
||||
container.parent?.type === 'JSXExpressionContainer' &&
|
||||
container.parent.parent?.type === 'JSXElement' &&
|
||||
hasSiblings(container.parent)
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `node` renders after a sibling conditional, i.e. the DOM order Translate breaks. */
|
||||
function followsConditionalSibling(node) {
|
||||
return (
|
||||
node.parent?.parent?.type === 'JSXElement' &&
|
||||
hasSiblings(node.parent) &&
|
||||
isPrecededByConditional(node.parent)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `A && B` and the test of a ternary are conditions, not rendered output.
|
||||
*/
|
||||
function isCondition(node) {
|
||||
let current = node;
|
||||
while (current.parent?.type === 'LogicalExpression') {
|
||||
if (current.parent.left === current) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
if (current.parent?.type === 'ConditionalExpression') {
|
||||
return current.parent.test === current;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isConditionOperand(node) {
|
||||
if (node.parent?.type === 'BinaryExpression') {
|
||||
return isCondition(node.parent);
|
||||
}
|
||||
return isCondition(node);
|
||||
}
|
||||
|
||||
// A string may only be inlined as JSX text when it needs no escaping and no
|
||||
// whitespace of its own: JSX collapses leading and trailing whitespace, and
|
||||
// these characters would either close the element or start an entity.
|
||||
const NEEDS_BRACES = /['"{}<>&\r\n]/;
|
||||
|
||||
// `display: contents`, declared in src/styles.scss. React owns the element so
|
||||
// Translate's `<font>` swap is absorbed, while the box tree stays as it was and
|
||||
// a flex or grid parent still sees one contiguous text run.
|
||||
const OPEN = '<span className="translate-safe">';
|
||||
const CLOSE = '</span>';
|
||||
|
||||
/** Wraps the reported expression so React owns an element Translate cannot replace. */
|
||||
function wrapExpression(fixer, sourceCode, node) {
|
||||
// A call reported on its own already sits in a container. Replacing the
|
||||
// container yields `<span …>{expr}</span>` rather than `{<span …>{expr}</span>}`.
|
||||
const target =
|
||||
node.parent?.type === 'JSXExpressionContainer' ? node.parent : node;
|
||||
|
||||
if (
|
||||
node.type === 'Literal' &&
|
||||
typeof node.value === 'string' &&
|
||||
!NEEDS_BRACES.test(node.value) &&
|
||||
node.value.trim() === node.value
|
||||
) {
|
||||
return fixer.replaceText(target, `${OPEN}${node.value}${CLOSE}`);
|
||||
}
|
||||
|
||||
return fixer.replaceText(
|
||||
target,
|
||||
`${OPEN}{${sourceCode.getText(node)}}${CLOSE}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps static JSX text. Only the visible run is wrapped: the node also spans
|
||||
* the formatting whitespace around it, which has to stay outside the element.
|
||||
*/
|
||||
function wrapJsxText(fixer, node) {
|
||||
const raw = node.value;
|
||||
const leading = raw.length - raw.trimStart().length;
|
||||
const trailing = raw.length - raw.trimEnd().length;
|
||||
return fixer.replaceTextRange(
|
||||
[node.start + leading, node.end - trailing],
|
||||
`${OPEN}${raw.trim()}${CLOSE}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Conditionally rendered text nodes should be wrapped in an element (for example a `<span>`), otherwise Google Translate can cause a browser error.',
|
||||
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
|
||||
},
|
||||
schema: [],
|
||||
// Wrapping adds a DOM element, which can turn into a flex/grid item or
|
||||
// break `> *` and `:nth-child` selectors, so it is offered as a suggestion
|
||||
// (`--fix-suggestions`) rather than applied by a bare `--fix`.
|
||||
hasSuggestions: true,
|
||||
messages: {
|
||||
'conditional-text-node':
|
||||
'Conditionally rendered text nodes with siblings, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">{value}</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`. This also applies to values returned from functions, so `getString()` becomes `<span className="translate-safe">{getString()}</span>`.',
|
||||
'text-node-preceded-by-conditional':
|
||||
'Text nodes which are preceded by a conditional expression, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">text</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`.',
|
||||
},
|
||||
},
|
||||
|
||||
createOnce(context) {
|
||||
const suggestWrap = (build) => [{ desc: 'Wrap in a <span>', fix: build }];
|
||||
|
||||
const wrap = (fixer, node) => wrapExpression(fixer, context.sourceCode, node);
|
||||
|
||||
const reportConditional = (node) => {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'conditional-text-node',
|
||||
suggest: suggestWrap((fixer) => wrap(fixer, node)),
|
||||
});
|
||||
};
|
||||
|
||||
const reportPreceded = (node, build) => {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'text-node-preceded-by-conditional',
|
||||
suggest: suggestWrap(build),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// String and numeric branches: `{flag ? 'yes' : 'no'}`
|
||||
Literal(node) {
|
||||
if (node.value === null || typeof node.value === 'boolean') {
|
||||
return;
|
||||
}
|
||||
if (isBlankText(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
|
||||
TemplateLiteral(node) {
|
||||
if (isBlankText(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
|
||||
// Static text rendered after a conditional: `{flag && <b/>}trailing`
|
||||
JSXText(node) {
|
||||
if (isBlankText(node)) {
|
||||
return;
|
||||
}
|
||||
if (hasSiblings(node) && isPrecededByConditional(node)) {
|
||||
reportPreceded(node, (fixer) => wrapJsxText(fixer, node));
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (isCondition(node) || !isTextReturningCall(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
if (followsConditionalSibling(node)) {
|
||||
reportPreceded(node, (fixer) => wrap(fixer, node));
|
||||
}
|
||||
},
|
||||
|
||||
// Values read off an object: `{flag ? user.name : 'anonymous'}`
|
||||
MemberExpression(node) {
|
||||
if (isConditionOperand(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
|
||||
// Optional chaining wraps the member expression: `{flag ? a?.b?.c : 'x'}`
|
||||
ChainExpression(node) {
|
||||
if (isConditionOperand(node)) {
|
||||
return;
|
||||
}
|
||||
if (isProblematicConditional(node)) {
|
||||
reportConditional(node);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Rule: no-return-text-nodes
|
||||
*
|
||||
* React components should not return a bare text node. Google Translate keeps
|
||||
* displaying the stale translated text after a state change and nothing throws,
|
||||
* which makes the bug very hard to track down. Numbers count too: JSX renders
|
||||
* them as text.
|
||||
*
|
||||
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
|
||||
* (v1.0.4). Upstream walks the function body statement by statement; this
|
||||
* version visits `ReturnStatement` directly and walks up to the enclosing
|
||||
* function, which covers the same constructs without enumerating them.
|
||||
*/
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
]);
|
||||
|
||||
function isTextNode(node) {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
node.type === 'Literal' &&
|
||||
(typeof node.value === 'string' || typeof node.value === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
function getEnclosingFunction(node) {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (FUNCTION_TYPES.has(current.type)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isComponentName(name) {
|
||||
return (
|
||||
typeof name === 'string' && name !== '' && name[0] === name[0].toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
// The suggestion introduces JSX, which only parses in a JSX-enabled file.
|
||||
function allowsJsx(filename) {
|
||||
return filename.endsWith('.tsx') || filename.endsWith('.jsx');
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
|
||||
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
|
||||
},
|
||||
schema: [],
|
||||
// Wrapping changes what the component renders, so it is offered as a
|
||||
// suggestion (`--fix-suggestions`) rather than applied by a bare `--fix`.
|
||||
hasSuggestions: true,
|
||||
messages: {
|
||||
'return-value-is-text-node':
|
||||
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
|
||||
},
|
||||
},
|
||||
|
||||
createOnce(context) {
|
||||
const buildSuggestion = (node) => {
|
||||
if (!allowsJsx(context.filename)) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
{
|
||||
desc: 'Wrap in a <span>',
|
||||
fix: (fixer) =>
|
||||
fixer.replaceText(
|
||||
node.argument,
|
||||
`<span className="translate-safe">{${context.sourceCode.getText(node.argument)}}</span>`,
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return {
|
||||
ReturnStatement(node) {
|
||||
if (!isTextNode(node.argument)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only named function declarations are recognised as components, so a
|
||||
// text return from a nested helper or a class method is left alone.
|
||||
const fn = getEnclosingFunction(node);
|
||||
if (fn?.type !== 'FunctionDeclaration') {
|
||||
return;
|
||||
}
|
||||
if (!isComponentName(fn.id?.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'return-value-is-text-node',
|
||||
suggest: buildSuggestion(node),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -13,8 +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 noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
|
||||
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -29,7 +27,5 @@ export default {
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
|
||||
'no-return-text-nodes': noReturnTextNodes,
|
||||
},
|
||||
};
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -294,9 +294,6 @@ importers:
|
||||
timestamp-nano:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.1
|
||||
translation-resilience:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
typescript:
|
||||
specifier: 5.9.3
|
||||
version: 5.9.3
|
||||
@@ -8478,9 +8475,6 @@ packages:
|
||||
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
translation-resilience@0.2.0:
|
||||
resolution: {integrity: sha512-IxTjhpHGp1SJxVEEPBu/YbBaHnymMRJdYxUY1i5qtYADuz9b8fdWhZSE/EeRS2aVIiuQjRqtYDk69ruS/3fzTg==}
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
@@ -18238,8 +18232,6 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
translation-resilience@0.2.0: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
trough@2.1.0: {}
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import {
|
||||
completionStatus,
|
||||
currentCompletions,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
|
||||
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { RECENTS_DISPLAY_CAP, RECENTS_SECTION } from '../QuerySearch/constants';
|
||||
import QuerySearch from '../QuerySearch/QuerySearch';
|
||||
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
|
||||
|
||||
const CM_ROOT_SELECTOR = '.cm-editor';
|
||||
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
|
||||
const TOOLTIP_SELECTOR = '.cm-tooltip-autocomplete';
|
||||
const COMPLETION_LABEL_SELECTOR = '.cm-completionLabel';
|
||||
const DELETE_BUTTON_SELECTOR = '.cm-recent-delete';
|
||||
|
||||
const FRONTEND_FILTER = "service.name = 'frontend'";
|
||||
const STATUS_CODE_FILTER = "http.status_code = '500'";
|
||||
const TRACES_FILTER = "name = 'HTTP GET'";
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): { dashboardData: undefined } => ({
|
||||
dashboardData: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { keys: {} } },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderLogsSearch(onChange: (value: string) => void = jest.fn()): void {
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function saveLogsRecent(expression: string): void {
|
||||
recentQueriesStore.save({ signal: 'logs', filter: { expression } });
|
||||
}
|
||||
|
||||
function getEditorView(): EditorView | null {
|
||||
const root = document.querySelector<HTMLElement>(CM_ROOT_SELECTOR);
|
||||
return root ? EditorView.findFromDOM(root) : null;
|
||||
}
|
||||
|
||||
function getDocText(): string {
|
||||
return getEditorView()?.state.doc.toString() ?? '';
|
||||
}
|
||||
|
||||
function isCompletionOpen(): boolean {
|
||||
const view = getEditorView();
|
||||
return !!view && completionStatus(view.state) === 'active';
|
||||
}
|
||||
|
||||
// Reads recents from completion state, not the tooltip: the tooltip is a later render
|
||||
// pass over this same state, so going to the source drops a layer of timing.
|
||||
function getRecentLabels(): string[] {
|
||||
const view = getEditorView();
|
||||
if (!view) {
|
||||
return [];
|
||||
}
|
||||
return currentCompletions(view.state)
|
||||
.filter((completion) => completion.section === RECENTS_SECTION)
|
||||
.map((completion) => completion.label);
|
||||
}
|
||||
|
||||
async function renderAndFocus(
|
||||
onChange: (value: string) => void = jest.fn(),
|
||||
): Promise<HTMLElement> {
|
||||
renderLogsSearch(onChange);
|
||||
|
||||
const editor = await waitFor(
|
||||
() => {
|
||||
const element = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(element).toBeInTheDocument();
|
||||
return element as HTMLElement;
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
await userEvent.click(editor);
|
||||
return editor;
|
||||
}
|
||||
|
||||
function openRecents(): Promise<void> {
|
||||
return waitFor(
|
||||
() => {
|
||||
const view = getEditorView();
|
||||
if (view && !isCompletionOpen()) {
|
||||
startCompletion(view);
|
||||
}
|
||||
expect(getRecentLabels().length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
describe('QuerySearch recent searches', () => {
|
||||
beforeEach(() => {
|
||||
recentQueriesStore.useRecentQueriesStore.setState({ buckets: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows a saved recent query under "Recent searches" on focus', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
const view = getEditorView() as EditorView;
|
||||
const [recent] = currentCompletions(view.state);
|
||||
expect(recent.section).toBe(RECENTS_SECTION);
|
||||
});
|
||||
|
||||
it('filters recents by substring as the user types', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
saveLogsRecent(STATUS_CODE_FILTER);
|
||||
|
||||
const editor = await renderAndFocus();
|
||||
await openRecents();
|
||||
await userEvent.type(editor, 'status_code');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getRecentLabels()).toStrictEqual([STATUS_CODE_FILTER]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not surface recents saved under a different signal', async () => {
|
||||
recentQueriesStore.save({
|
||||
signal: 'traces',
|
||||
filter: { expression: TRACES_FILTER },
|
||||
});
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes a recent that exactly matches the current editor text', async () => {
|
||||
const supersetFilter = `${FRONTEND_FILTER} AND ${STATUS_CODE_FILTER}`;
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
saveLogsRecent(supersetFilter);
|
||||
|
||||
const editor = await renderAndFocus();
|
||||
await openRecents();
|
||||
await userEvent.type(editor, FRONTEND_FILTER);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getRecentLabels()).toStrictEqual([supersetFilter]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the dropdown at RECENTS_DISPLAY_CAP entries, newest first', async () => {
|
||||
const filters = Array.from(
|
||||
{ length: RECENTS_DISPLAY_CAP + 1 },
|
||||
(_, index) => `attribute_${index + 1} = 'v'`,
|
||||
);
|
||||
filters.forEach((filter) => saveLogsRecent(filter));
|
||||
const expectedLabels = [...filters].reverse().slice(0, RECENTS_DISPLAY_CAP);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getRecentLabels()).toStrictEqual(expectedLabels);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the full expression to the editor when a recent is clicked', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
const onChange = jest.fn();
|
||||
await renderAndFocus(onChange);
|
||||
await openRecents();
|
||||
|
||||
const option = await waitFor(
|
||||
() => {
|
||||
const node = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
|
||||
).find((element) => element.textContent === FRONTEND_FILTER);
|
||||
expect(node).toBeDefined();
|
||||
return node as HTMLElement;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await userEvent.click(option);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getDocText()).toBe(FRONTEND_FILTER);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(FRONTEND_FILTER);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(TOOLTIP_SELECTOR)).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a recent from the dropdown and the store when delete is clicked', async () => {
|
||||
saveLogsRecent(FRONTEND_FILTER);
|
||||
|
||||
await renderAndFocus();
|
||||
await openRecents();
|
||||
|
||||
const deleteButton = await waitFor(
|
||||
() => {
|
||||
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
|
||||
expect(button).toBeInTheDocument();
|
||||
return button as HTMLElement;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(recentQueriesStore.list('logs')).toHaveLength(0);
|
||||
expect(getRecentLabels()).not.toContain(FRONTEND_FILTER);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
expect(getDocText()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -124,9 +124,7 @@ export function buildBaseConfig({
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
// Graph and bar plot time on X; every other panel type here does not.
|
||||
isTimeAxis:
|
||||
panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR,
|
||||
panelType,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -136,6 +134,7 @@ export function buildBaseConfig({
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -75,7 +76,7 @@ export function buildEntityMetricsChartConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isTimeAxis: true,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -84,6 +85,7 @@ export function buildEntityMetricsChartConfig({
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
|
||||
@@ -1617,13 +1617,13 @@ export const getNamespaceMetricsQueryPayload = (
|
||||
reduceTo: ReduceOperators.LAST,
|
||||
spaceAggregation: 'max',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'latest',
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [
|
||||
{
|
||||
disabled: false,
|
||||
expression: '(B/A) * 100',
|
||||
expression: 'A/B',
|
||||
legend: 'util %',
|
||||
queryName: 'F1',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
@@ -72,7 +73,7 @@ export function buildMeterChartConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isTimeAxis: true,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -81,6 +82,7 @@ export function buildMeterChartConfig({
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
|
||||
@@ -1521,9 +1521,9 @@ const onboardingConfigWithLinks = [
|
||||
},
|
||||
{
|
||||
dataSource: 'nginx-tracing',
|
||||
label: 'Nginx - OpenTelemetry',
|
||||
label: 'Nginx - Tracing',
|
||||
imgUrl: nginxUrl,
|
||||
tags: ['apm/traces', 'logs', 'metrics'],
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apm',
|
||||
@@ -1626,7 +1626,7 @@ const onboardingConfigWithLinks = [
|
||||
dataSource: 'cloudflare-workers',
|
||||
label: 'Cloudflare Workers',
|
||||
imgUrl: cloudflareUrl,
|
||||
tags: ['apm/traces', 'logs'],
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'cloudflare',
|
||||
@@ -5346,17 +5346,13 @@ const onboardingConfigWithLinks = [
|
||||
dataSource: 'temporal',
|
||||
label: 'Temporal',
|
||||
imgUrl: temporalUrl,
|
||||
tags: ['apm/traces', 'logs', 'metrics'],
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apm',
|
||||
'application performance monitoring',
|
||||
'integrations',
|
||||
'logs',
|
||||
'metrics',
|
||||
'temporal',
|
||||
'temporal logs',
|
||||
'temporal metrics',
|
||||
'temporal traces',
|
||||
'traces',
|
||||
'tracing',
|
||||
@@ -5482,7 +5478,7 @@ const onboardingConfigWithLinks = [
|
||||
dataSource: 'dbos',
|
||||
label: 'DBOS',
|
||||
imgUrl: dbosUrl,
|
||||
tags: ['apm/traces', 'logs'],
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'database oriented',
|
||||
@@ -6626,7 +6622,7 @@ const onboardingConfigWithLinks = [
|
||||
dataSource: 'opentelemetry-ebpf',
|
||||
label: 'OpenTelemetry eBPF (OBI)',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['apm/traces', 'metrics'],
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'auto instrumentation',
|
||||
|
||||
@@ -13,13 +13,11 @@ import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
import { installTranslationResilience } from 'translation-resilience';
|
||||
|
||||
import './ReactI18';
|
||||
|
||||
import 'styles.scss';
|
||||
|
||||
installTranslationResilience();
|
||||
configureOverlayScrollbars();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
@@ -6,6 +7,11 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.BAR,
|
||||
];
|
||||
|
||||
/**
|
||||
* Builder for uPlot axis configuration
|
||||
* Handles creation and merging of axis settings
|
||||
@@ -61,9 +67,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
* Build values formatter for X-axis (time)
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { isTimeAxis } = this.props;
|
||||
const { panelType } = this.props;
|
||||
|
||||
if (isTimeAxis) {
|
||||
if (
|
||||
panelType &&
|
||||
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
|
||||
) {
|
||||
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
@@ -136,11 +137,11 @@ describe('UPlotAxisBuilder', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
isTimeAxis: true,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -149,11 +150,11 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter for a non-time axis', () => {
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
isTimeAxis: false,
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -289,9 +290,22 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('omits the X-axis datetime formatter when no time axis is declared', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
|
||||
expect(builder.getConfig().values).toBeUndefined();
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
|
||||
const timeSeriesBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('should return the existing size when cycleNum > 1', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { ThresholdsDrawHookOptions } from '../hooks/types';
|
||||
@@ -69,12 +70,7 @@ export interface AxisProps {
|
||||
isDarkMode?: boolean;
|
||||
isLogScale?: boolean;
|
||||
yAxisUnit?: string;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
|
||||
* (histogram) leaves it off.
|
||||
*/
|
||||
isTimeAxis?: boolean;
|
||||
panelType?: PANEL_TYPES;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
} from '../../Panels/capabilities';
|
||||
import { getPanelDefinition } from '../../Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -64,10 +64,8 @@ function PanelEditorQueryBuilder({
|
||||
footer,
|
||||
stickyHeader = true,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
|
||||
// builder offers for this kind comes from the kind's own declaration.
|
||||
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const { listView, traceOperator } = getPanelDefinition(panelKind).query;
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -114,9 +112,9 @@ function PanelEditorQueryBuilder({
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={traceOperator}
|
||||
showTraceOperator={panelType !== PANEL_TYPES.LIST}
|
||||
version="v3"
|
||||
isListViewPanel={listView}
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface PlotTagProps {
|
||||
/** Authoring mode of the panel's query; undefined when no query exists yet. */
|
||||
queryType: EQueryType | undefined;
|
||||
/** Kind is authored as a list view — nothing is plotted, so the chip has nothing to say. */
|
||||
isListView: boolean;
|
||||
panelType: PANEL_TYPES;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
|
||||
* PlotTag (duplicated per the split policy). Hidden for list views and before a
|
||||
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
|
||||
* query exists, where the mode is irrelevant.
|
||||
*/
|
||||
function PlotTag({
|
||||
queryType,
|
||||
isListView,
|
||||
panelType,
|
||||
className,
|
||||
}: PlotTagProps): JSX.Element | null {
|
||||
if (queryType === undefined || isListView) {
|
||||
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSection
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
@@ -71,6 +72,7 @@ function PreviewPane({
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
@@ -84,7 +86,7 @@ function PreviewPane({
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
isListView={panelDefinition.query.listView}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from '../PlotTag';
|
||||
|
||||
describe('PlotTag', () => {
|
||||
it('renders the resolved query mode', () => {
|
||||
render(<PlotTag queryType={EQueryType.PROM} isListView={false} />);
|
||||
render(
|
||||
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no query yet', () => {
|
||||
render(<PlotTag queryType={undefined} isListView={false} />);
|
||||
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for a list view (query mode is irrelevant)', () => {
|
||||
render(<PlotTag queryType={EQueryType.QUERY_BUILDER} isListView />);
|
||||
it('renders nothing for list panels (query mode is irrelevant)', () => {
|
||||
render(
|
||||
<PlotTag
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,7 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getPanelDefinition,
|
||||
isPanelKindSupported,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
@@ -94,9 +91,8 @@ export function usePanelEditSession({
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
queryCapabilities: panelDefinition.query,
|
||||
time,
|
||||
enabled: isPanelKindSupported(panelKind),
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
type PartialPanelTypes,
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import { getPanelDefinition } from '../../Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -145,10 +144,11 @@ export function usePanelTypeSwitch({
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list view's default order so the builder's Order By isn't empty.
|
||||
const nextQuery = getPanelDefinition(newKind).query.listView
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const nextQuery =
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
|
||||
import { getPanelDefinition, isPanelKindSupported } from '../registry';
|
||||
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
@@ -22,7 +15,6 @@ import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
|
||||
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
|
||||
|
||||
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
@@ -45,131 +37,9 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/ListPanel': [logs, traces],
|
||||
};
|
||||
|
||||
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
|
||||
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
|
||||
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
// Bar bins client-side, so it asks for a widened step interval over a raw series.
|
||||
'signoz/BarChartPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: true,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
'signoz/HistogramPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
'signoz/NumberPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
'signoz/PieChartPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
// Only Table asks the server to transpose its scalar result into UI rows.
|
||||
'signoz/TablePanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: true,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
|
||||
'signoz/ListPanel': {
|
||||
requestType: raw,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
listView: true,
|
||||
traceOperator: false,
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
|
||||
|
||||
describe('panel capabilities guard', () => {
|
||||
describe('query capabilities', () => {
|
||||
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
|
||||
expect(getPanelDefinition(kind).query).toStrictEqual(
|
||||
EXPECTED_QUERY_CAPABILITIES[kind],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A dashboard spec written by a newer SigNoz can name a kind this build has no
|
||||
// definition for. The registry answers with UNSUPPORTED_PANEL rather than nothing, so
|
||||
// every guard below reads it without first proving a definition exists.
|
||||
describe('a kind this build cannot render', () => {
|
||||
const unknownKind = 'signoz/SomeFutureKindPanel' as PanelKind;
|
||||
|
||||
it('is not reported as supported', () => {
|
||||
expect(isPanelKindSupported(unknownKind)).toBe(false);
|
||||
expect(isPanelKindSupported('signoz/TimeSeriesPanel')).toBe(true);
|
||||
});
|
||||
|
||||
it('still resolves to a definition', () => {
|
||||
expect(getPanelDefinition(unknownKind)).toBe(UNSUPPORTED_PANEL);
|
||||
});
|
||||
|
||||
it('declares nothing, so it is never offered as authorable', () => {
|
||||
expect(getSupportedSignals(unknownKind)).toStrictEqual([]);
|
||||
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
|
||||
expect(isSignalSupported(unknownKind, logs)).toBe(false);
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
|
||||
).toBe(false);
|
||||
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
|
||||
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('offers no actions', () => {
|
||||
expect(getPanelDefinition(unknownKind).actions).toStrictEqual(
|
||||
NO_PANEL_ACTIONS,
|
||||
);
|
||||
expect(NO_PANEL_ACTIONS.view).toBe(false);
|
||||
expect(NO_PANEL_ACTIONS.edit).toBe(false);
|
||||
expect(NO_PANEL_ACTIONS.drilldown).toBe(false);
|
||||
});
|
||||
|
||||
it('carries an inert query shape, so a stray request can do no harm', () => {
|
||||
const { query } = getPanelDefinition(unknownKind);
|
||||
expect(query.requestType).toBe(time_series);
|
||||
expect(query.serverPaginated).toBe(false);
|
||||
expect(query.formatTableResultForUI).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query type support', () => {
|
||||
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
|
||||
expect(getSupportedQueryTypes(kind)).toStrictEqual(
|
||||
|
||||
@@ -53,10 +53,9 @@ function NoData({
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
|
||||
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
|
||||
const panelKind = panel?.spec.plugin.kind;
|
||||
const panelType = panelKind ? PANEL_KIND_TO_PANEL_TYPE[panelKind] : undefined;
|
||||
const panelType = panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
|
||||
: undefined;
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
@@ -66,7 +65,6 @@ function NoData({
|
||||
void logEvent(DashboardDetailEvents.NoDataAction, {
|
||||
action: 'extendTime',
|
||||
panelType,
|
||||
panelKind,
|
||||
});
|
||||
activeExtend.extend();
|
||||
},
|
||||
@@ -81,7 +79,6 @@ function NoData({
|
||||
void logEvent(DashboardDetailEvents.NoDataAction, {
|
||||
action: 'retry',
|
||||
panelType,
|
||||
panelKind,
|
||||
});
|
||||
onRetry();
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
@@ -23,17 +20,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
// Bars are binned client-side from a raw time series, so the request asks for a
|
||||
// step interval wide enough to keep the bar count readable (V1 parity).
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: true,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
@@ -47,7 +48,7 @@ export function buildBarChartConfig({
|
||||
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
isTimeAxis: true,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
@@ -23,17 +20,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
// Buckets are computed client-side from the raw series, so the request is a plain
|
||||
// time series — the bucket count is a display concern, not a query one.
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
@@ -43,7 +44,7 @@ export function buildHistogramConfig({
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
isTimeAxis: false,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -33,17 +30,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
},
|
||||
},
|
||||
sections,
|
||||
// The only kind reading raw rows: they page server-side, and the sort needs a
|
||||
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
listView: true,
|
||||
traceOperator: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
@@ -23,15 +20,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
@@ -19,15 +16,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
queryBuilderFields: {},
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
@@ -19,16 +16,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
queryBuilderFields: {},
|
||||
// The only kind that asks the server to transpose its scalar result into UI rows.
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: true,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
@@ -23,15 +20,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
@@ -65,7 +66,7 @@ export function buildTimeSeriesConfig({
|
||||
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
isTimeAxis: true,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
|
||||
import PanelMessage from '../../components/PanelMessage/PanelMessage';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
|
||||
/**
|
||||
* Body for a panel whose kind this build has no renderer for — a spec written by a newer
|
||||
* SigNoz names a visualization that didn't exist when this client shipped. Says so in
|
||||
* place of the chart, so the panel keeps its slot in the layout instead of leaving a hole.
|
||||
*/
|
||||
function UnsupportedPanelRenderer(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="unsupported-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
<PanelMessage
|
||||
icon={<CircleHelp size={18} />}
|
||||
title="Unsupported panel type"
|
||||
description="This panel was built with a newer version of SigNoz. Upgrade to view it."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnsupportedPanelRenderer;
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
NO_PANEL_ACTIONS,
|
||||
type RenderablePanelDefinition,
|
||||
} from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
|
||||
/**
|
||||
* Stand-in definition for a kind that isn't in the registry, so `getPanelDefinition`
|
||||
* always resolves and no caller has to branch on a missing one. It declares nothing: no
|
||||
* signals, no query types, no config sections and no actions — an unknown kind can't be
|
||||
* queried, configured or acted on, only shown as unsupported.
|
||||
*
|
||||
* `kind` carries a sentinel that no API enum value can collide with; the cast is the one
|
||||
* place this definition steps outside `PanelKind`.
|
||||
*/
|
||||
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
|
||||
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
|
||||
displayName: 'Unsupported panel',
|
||||
Renderer,
|
||||
sections: [],
|
||||
supportedSignals: [],
|
||||
supportedQueryTypes: [],
|
||||
queryBuilderFields: {},
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: false,
|
||||
},
|
||||
actions: NO_PANEL_ACTIONS,
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { definition as PieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import { definition as Table } from './kinds/TablePanel/definition';
|
||||
import { definition as List } from './kinds/ListPanel/definition';
|
||||
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
|
||||
import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
@@ -23,24 +22,8 @@ export const PANELS: PanelRegistry = {
|
||||
[List.kind]: List,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
|
||||
* but a dashboard spec written by a newer SigNoz can name one this client has never heard
|
||||
* of — so ask before doing work on a panel's behalf, such as fetching its data.
|
||||
*/
|
||||
export function isPanelKindSupported(kind: PanelKind): boolean {
|
||||
return kind in PANELS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition for a kind — always one. An unregistered kind resolves to
|
||||
* {@link UNSUPPORTED_PANEL}, which declares no capabilities and renders as unsupported, so
|
||||
* callers read a definition's fields without first proving it exists.
|
||||
*/
|
||||
export function getPanelDefinition(kind: PanelKind): RenderablePanelDefinition {
|
||||
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
|
||||
// prop surface (a per-kind renderer can't be statically validated against the union).
|
||||
return (
|
||||
(PANELS[kind] as RenderablePanelDefinition | undefined) ?? UNSUPPORTED_PANEL
|
||||
);
|
||||
return PANELS[kind] as RenderablePanelDefinition;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
|
||||
/**
|
||||
@@ -21,37 +18,3 @@ export type FilterConfigsPartial = NonNullable<
|
||||
export type QueryBuilderFieldRule = {
|
||||
default?: FilterConfigsPartial;
|
||||
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
|
||||
|
||||
/**
|
||||
* How a kind's query-range request is shaped. Declared per-kind in
|
||||
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code
|
||||
* has to translate a panel kind into the legacy `PANEL_TYPES` enum to answer these.
|
||||
*/
|
||||
export interface PanelQueryCapabilities {
|
||||
/** V5 request type the panel's data comes back as. */
|
||||
requestType: Querybuildertypesv5RequestTypeDTO;
|
||||
/** Server transposes the scalar result into UI table rows (`formatOptions.formatTableResultForUI`). */
|
||||
formatTableResultForUI: boolean;
|
||||
/**
|
||||
* Widen the step interval to cap how many buckets come back — kinds that bin
|
||||
* client-side from a raw time series rather than plotting every point.
|
||||
*/
|
||||
bucketedStepInterval: boolean;
|
||||
/**
|
||||
* Append a deterministic tiebreaker to the query's `order` so offset paging over raw
|
||||
* rows can't repeat or skip a row when the sort key has duplicates.
|
||||
*/
|
||||
orderTiebreaker: boolean;
|
||||
/**
|
||||
* Rows page server-side via `offset`/`limit`. AND-ed at the call site with "the query
|
||||
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
|
||||
*/
|
||||
serverPaginated: boolean;
|
||||
/**
|
||||
* Authored as a list view: the query builder drops its aggregation controls, and the
|
||||
* editor preview hides the plot-mode chip because nothing is plotted.
|
||||
*/
|
||||
listView: boolean;
|
||||
/** Query builder offers a trace operator alongside the builder queries. */
|
||||
traceOperator: boolean;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ import type { EQueryType } from 'types/common/dashboard';
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldRule,
|
||||
} from './panelCapabilities';
|
||||
import type { QueryBuilderFieldRule } from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/** Export formats offered under the single "Download" action. */
|
||||
@@ -42,24 +39,6 @@ export interface PanelActionCapabilities {
|
||||
drilldown: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* No actions at all — for a kind this build can't render, where every action would act on
|
||||
* a panel body that isn't there. See `UNSUPPORTED_PANEL`.
|
||||
*/
|
||||
export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
|
||||
view: false,
|
||||
edit: false,
|
||||
clone: false,
|
||||
download: {
|
||||
[DownloadFormat.CSV]: false,
|
||||
[DownloadFormat.PNG]: false,
|
||||
[DownloadFormat.SVG]: false,
|
||||
},
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
};
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
@@ -71,8 +50,6 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
supportedQueryTypes: EQueryType[];
|
||||
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
|
||||
queryBuilderFields: QueryBuilderFieldRule;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
query: PanelQueryCapabilities;
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelQueryCapabilities } from '../../types/panelCapabilities';
|
||||
import { buildDefaultQueries } from '../buildDefaultQueries';
|
||||
|
||||
// What a plotted kind and a list-view kind declare. Passed in rather than resolved from
|
||||
// the registry, which would pull every panel renderer into this suite.
|
||||
const PLOTTED_CAPS: PanelQueryCapabilities = {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
};
|
||||
const LIST_CAPS: PanelQueryCapabilities = {
|
||||
...PLOTTED_CAPS,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
listView: true,
|
||||
traceOperator: false,
|
||||
};
|
||||
|
||||
describe('buildDefaultQueries', () => {
|
||||
it('seeds a list view with a runnable logs query ordered by timestamp desc', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel', LIST_CAPS);
|
||||
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel');
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
// orderBy timestamp desc must survive serialization so the preview opens
|
||||
@@ -36,20 +13,16 @@ describe('buildDefaultQueries', () => {
|
||||
expect(serialized.toLowerCase()).toContain('logs');
|
||||
});
|
||||
|
||||
it('seeds a list view without a limit so it pages server-side by default', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel', LIST_CAPS);
|
||||
it('seeds a List panel without a limit so it pages server-side by default', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel');
|
||||
|
||||
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
|
||||
const spec = queries[0].spec.plugin.spec as { limit?: number };
|
||||
expect(spec.limit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds no query for plotted kinds (they seed from the builder)', () => {
|
||||
expect(
|
||||
buildDefaultQueries('signoz/TimeSeriesPanel', PLOTTED_CAPS),
|
||||
).toStrictEqual([]);
|
||||
expect(buildDefaultQueries('signoz/NumberPanel', PLOTTED_CAPS)).toStrictEqual(
|
||||
[],
|
||||
);
|
||||
it('seeds no query for non-List kinds (they seed from the builder)', () => {
|
||||
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
|
||||
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import onClickPlugin, {
|
||||
OnClickPluginOpts,
|
||||
@@ -25,11 +26,7 @@ import {
|
||||
*/
|
||||
export interface BuildBaseConfigArgs {
|
||||
panelId: string;
|
||||
/**
|
||||
* X axis plots timestamps, so its ticks format as dates/times. Each kind states this
|
||||
* for itself — a bucketed x axis (histogram) passes false.
|
||||
*/
|
||||
isTimeAxis: boolean;
|
||||
panelType: PANEL_TYPES;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
@@ -66,7 +63,7 @@ export interface BuildBaseConfigArgs {
|
||||
*/
|
||||
export function buildBaseConfig({
|
||||
panelId,
|
||||
isTimeAxis,
|
||||
panelType,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
@@ -136,7 +133,7 @@ export function buildBaseConfig({
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
isTimeAxis,
|
||||
panelType,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -146,6 +143,7 @@ export function buildBaseConfig({
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { listViewInitialLogQuery } from 'constants/queryBuilder';
|
||||
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
|
||||
|
||||
/** Seed query for a new panel. Only a list view needs one (logs, timestamp desc) so its
|
||||
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
|
||||
* preview runs on open; other kinds start empty and seed from the builder. */
|
||||
export function buildDefaultQueries(
|
||||
kind: PanelKind,
|
||||
queryCapabilities: PanelQueryCapabilities,
|
||||
): DashboardtypesQueryDTO[] {
|
||||
if (!queryCapabilities.listView) {
|
||||
return [];
|
||||
export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
|
||||
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
|
||||
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
|
||||
}
|
||||
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
|
||||
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import {
|
||||
getPanelDefinition,
|
||||
isPanelKindSupported,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
@@ -53,17 +50,15 @@ function Panel({
|
||||
|
||||
// Header search: only kinds that declare it render the box. The term is owned
|
||||
// here and threaded to both the header (input) and renderer (filter).
|
||||
const searchable = panelDefinition.actions.search;
|
||||
const searchable = !!panelDefinition?.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
queryCapabilities: panelDefinition.query,
|
||||
// Lazy: fetch only once on screen (undefined → visible), and never for a kind
|
||||
// this build can't render — the data would have nothing to render into.
|
||||
enabled: isPanelKindSupported(panelKind) && isVisible !== false,
|
||||
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
@@ -90,23 +85,25 @@ function Panel({
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isVisible={isVisible}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
{panelDefinition && (
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isVisible={isVisible}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type KeyboardEvent, useCallback } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelQueryBuilderProps {
|
||||
panelType: PANEL_TYPES;
|
||||
/** Preview fetch in flight — drives the Run/Cancel button state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Run Query button / ⌘↵). */
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch. */
|
||||
onCancelQuery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
|
||||
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
|
||||
* is query-builder only, exactly as V1.
|
||||
*/
|
||||
function ViewPanelQueryBuilder({
|
||||
panelType,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
}: ViewPanelQueryBuilderProps): JSX.Element {
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onStageRunQuery();
|
||||
}
|
||||
},
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.queryBuilder}
|
||||
data-testid="view-panel-query-builder"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
role="presentation"
|
||||
>
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
<div className={styles.queryBuilderToolbar}>
|
||||
<RightToolbarActions
|
||||
handleCancelQuery={onCancelQuery}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelQueryBuilder;
|
||||
@@ -148,9 +148,7 @@ describe('useCreateAlertFromPanel', () => {
|
||||
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queries: panel.spec.queries,
|
||||
queryCapabilities: expect.objectContaining({
|
||||
requestType: 'time_series',
|
||||
}),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
variables: { service: { type: 'query', value: 'checkout' } },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -81,7 +81,6 @@ export function useClonePanel({
|
||||
void logEvent(DashboardDetailEvents.PanelAction, {
|
||||
action: 'clone',
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[source.panel.spec.plugin.kind],
|
||||
panelKind: source.panel.spec.plugin.kind,
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useReplaceVariables } from 'api/generated/services/querier';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
@@ -45,15 +44,11 @@ export function useCreateAlertFromPanel(): (
|
||||
|
||||
return useCallback(
|
||||
(panel: DashboardtypesPanelDTO, panelId: string): void => {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
// Alerts are a V1 surface: the query pivots through the V1 `Query` shape and the
|
||||
// URL carries a legacy panel type, so this flow keeps translating.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
|
||||
void logEvent('Dashboard Detail: Panel action', {
|
||||
action: 'createAlerts',
|
||||
panelType,
|
||||
panelKind,
|
||||
dashboardId,
|
||||
widgetId: panelId,
|
||||
queryType: getPanelQueryType(panel),
|
||||
@@ -67,7 +62,7 @@ export function useCreateAlertFromPanel(): (
|
||||
// Redux global time is nanoseconds; the request DTO takes epoch ms.
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: panel.spec.queries,
|
||||
queryCapabilities: getPanelDefinition(panelKind).query,
|
||||
panelType,
|
||||
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
variables,
|
||||
|
||||
@@ -53,7 +53,6 @@ export function useDeletePanel({
|
||||
panelType: removed?.panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind]
|
||||
: undefined,
|
||||
panelKind: removed?.panel?.spec.plugin.kind,
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ export function useDownloadPanelCsv({
|
||||
void logEvent(DashboardDetailEvents.PanelExported, {
|
||||
format: 'csv',
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
panelKind: panel.spec.plugin.kind,
|
||||
});
|
||||
}, [canDownloadCsv, fileName, panel, data]);
|
||||
}
|
||||
|
||||
@@ -128,14 +128,11 @@ export function useDrilldown(
|
||||
|
||||
const onPanelClick = useCallback(
|
||||
(payload: DrilldownClickPayload): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownOpened, {
|
||||
panelType,
|
||||
panelKind: kind,
|
||||
});
|
||||
void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType });
|
||||
setSubMenu(DrilldownSubMenu.Base);
|
||||
onClick(payload.coordinates, payload.context);
|
||||
},
|
||||
[onClick, panelType, kind],
|
||||
[onClick, panelType],
|
||||
);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
@@ -179,8 +176,7 @@ export function useDrilldown(
|
||||
|
||||
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
|
||||
queries,
|
||||
panelKind: kind,
|
||||
queryCapabilities: getPanelDefinition(kind).query,
|
||||
panelType,
|
||||
v1Query,
|
||||
enabled: showAggregateMenu,
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ export function useMovePanelToSection({
|
||||
panelType: moved.panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind]
|
||||
: undefined,
|
||||
panelKind: moved.panel?.spec.plugin.kind,
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
@@ -3,11 +3,7 @@ import { useEffect, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useReplaceVariables } from 'api/generated/services/querier';
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
@@ -19,9 +15,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
interface UseResolvedDrilldownQueryArgs {
|
||||
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
panelKind: PanelKind;
|
||||
/** The panel kind's declared query capabilities — shapes the substitution request. */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
panelType: PANEL_TYPES;
|
||||
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
|
||||
v1Query: Query;
|
||||
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
|
||||
@@ -44,8 +38,7 @@ interface UseResolvedDrilldownQueryResult {
|
||||
*/
|
||||
export function useResolvedDrilldownQuery({
|
||||
queries,
|
||||
panelKind,
|
||||
queryCapabilities,
|
||||
panelType,
|
||||
v1Query,
|
||||
enabled,
|
||||
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
|
||||
@@ -67,7 +60,7 @@ export function useResolvedDrilldownQuery({
|
||||
substituteVars({
|
||||
data: buildQueryRangeRequest({
|
||||
queries,
|
||||
queryCapabilities,
|
||||
panelType,
|
||||
startMs: Math.floor(minTime / 1e6),
|
||||
endMs: Math.floor(maxTime / 1e6),
|
||||
variables,
|
||||
@@ -77,7 +70,7 @@ export function useResolvedDrilldownQuery({
|
||||
enabled,
|
||||
hasVariables,
|
||||
queries,
|
||||
queryCapabilities,
|
||||
panelType,
|
||||
minTime,
|
||||
maxTime,
|
||||
variables,
|
||||
@@ -88,13 +81,8 @@ export function useResolvedDrilldownQuery({
|
||||
if (!hasVariables || !data) {
|
||||
return v1Query;
|
||||
}
|
||||
// View-in-X navigates to a V1 explorer, so the resolved query crosses back into the
|
||||
// V1 `Query` shape — the one place this hook still needs a legacy panel type.
|
||||
return envelopesToQuery(
|
||||
data.data.compositeQuery?.queries ?? [],
|
||||
PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
);
|
||||
}, [hasVariables, data, v1Query, panelKind]);
|
||||
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
|
||||
}, [hasVariables, data, v1Query, panelType]);
|
||||
|
||||
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
@@ -58,27 +54,6 @@ function panelWith(
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// The capability blocks TimeSeries and List declare. Passed in rather than resolved from
|
||||
// the registry: the hook takes them as input, and importing the registry here would pull
|
||||
// every panel renderer (and the app's API client) into this suite.
|
||||
const TIME_SERIES_CAPS: PanelQueryCapabilities = {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
};
|
||||
const LIST_CAPS: PanelQueryCapabilities = {
|
||||
...TIME_SERIES_CAPS,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
listView: true,
|
||||
traceOperator: false,
|
||||
};
|
||||
|
||||
function builderPanel(): DashboardtypesPanelDTO {
|
||||
return panelWith('signoz/TimeSeriesPanel', {
|
||||
name: 'A',
|
||||
@@ -125,13 +100,7 @@ beforeEach(() => {
|
||||
|
||||
describe('usePanelQuery', () => {
|
||||
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.schemaVersion).toBe('v1');
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([
|
||||
@@ -143,30 +112,30 @@ describe('usePanelQuery', () => {
|
||||
});
|
||||
|
||||
it('converts redux nanosecond time to epoch ms on the request', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.start).toBe(1_000_000_000);
|
||||
expect(requestPayload.end).toBe(2_000_000_000);
|
||||
});
|
||||
|
||||
// Which requestType each kind declares is asserted in
|
||||
// Panels/__tests__/capabilities.test.ts; here it only has to reach the request.
|
||||
it('sends the requestType from the declared query capabilities', () => {
|
||||
it.each([
|
||||
['signoz/TimeSeriesPanel', 'time_series'],
|
||||
['signoz/ListPanel', 'raw'],
|
||||
// HISTOGRAM and BAR panels bin/derive from raw time-series data
|
||||
// client-side, so the backend must receive `time_series` (V1 parity).
|
||||
['signoz/HistogramPanel', 'time_series'],
|
||||
['signoz/BarChartPanel', 'time_series'],
|
||||
['signoz/NumberPanel', 'scalar'],
|
||||
['signoz/PieChartPanel', 'scalar'],
|
||||
])('%s panel sends requestType=%s', (panelKind, requestType) => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }),
|
||||
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.requestType).toBe('raw');
|
||||
expect(requestPayload.requestType).toBe(requestType);
|
||||
});
|
||||
|
||||
it('exposes the raw V5 response, request payload, and legend map on data', () => {
|
||||
@@ -179,11 +148,7 @@ describe('usePanelQuery', () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
|
||||
expect(result.current.data.response).toBe(v5Response);
|
||||
@@ -193,11 +158,7 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('exposes an undefined response before data arrives', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.data.response).toBeUndefined();
|
||||
});
|
||||
@@ -210,11 +171,7 @@ describe('usePanelQuery', () => {
|
||||
error: new Error('boom'),
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.error?.message).toBe('boom');
|
||||
});
|
||||
@@ -229,11 +186,7 @@ describe('usePanelQuery', () => {
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(true);
|
||||
@@ -247,11 +200,7 @@ describe('usePanelQuery', () => {
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
@@ -264,23 +213,14 @@ describe('usePanelQuery', () => {
|
||||
error: undefined,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('passes enabled=false to the fetch hook when the caller disables it', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
enabled: false,
|
||||
}),
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
@@ -288,12 +228,7 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: emptyPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
enabled: true,
|
||||
}),
|
||||
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
@@ -308,7 +243,6 @@ describe('usePanelQuery', () => {
|
||||
aggregations: [{}],
|
||||
}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
@@ -317,9 +251,7 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
|
||||
const panel = builderPanel();
|
||||
renderHook(() =>
|
||||
usePanelQuery({ panel, panelId: 'p1', queryCapabilities: TIME_SERIES_CAPS }),
|
||||
);
|
||||
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
|
||||
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -338,7 +270,6 @@ describe('usePanelQuery', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel,
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
|
||||
}),
|
||||
@@ -365,7 +296,6 @@ describe('usePanelQuery', () => {
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
|
||||
}),
|
||||
);
|
||||
@@ -386,11 +316,7 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('exposes server paging at the default page size when the query has no limit', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeDefined();
|
||||
expect(result.current.pagination?.pageSize).toBe(25);
|
||||
@@ -401,34 +327,20 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({ limit: 100 }),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps previous data while paging so the table/pager stay mounted on page change', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
);
|
||||
renderHook(() => usePanelQuery({ panel: listPanel({}), panelId: 'p1' }));
|
||||
const [{ keepPreviousData }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(keepPreviousData).toBe(true);
|
||||
});
|
||||
|
||||
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
|
||||
act(() => result.current.pagination?.setPageSize(50));
|
||||
@@ -468,11 +380,7 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('starts on page 0 with no prev/next and does not throw before data arrives', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
expect(result.current.pagination?.canPrev).toBe(false);
|
||||
@@ -484,33 +392,21 @@ describe('usePanelQuery', () => {
|
||||
// window/cursor path), so a full page is the has-more signal.
|
||||
withResponse(rawResponse(25));
|
||||
const fullPage = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(fullPage.result.current.pagination?.canNext).toBe(true);
|
||||
|
||||
// Partial page, no cursor → the last page.
|
||||
withResponse(rawResponse(3));
|
||||
const partialPage = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(partialPage.result.current.pagination?.canNext).toBe(false);
|
||||
|
||||
// Cursor present (even on a partial page) → more rows (timestamp window path).
|
||||
withResponse(rawResponse(3, 'cursor-1'));
|
||||
const withCursor = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(withCursor.result.current.pagination?.canNext).toBe(true);
|
||||
});
|
||||
@@ -520,9 +416,7 @@ describe('usePanelQuery', () => {
|
||||
// Stable panel reference: a fresh one each render would change the
|
||||
// `queries` identity and trip the offset-reset effect (real props are stable).
|
||||
const panel = listPanel({});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel, panelId: 'p1', queryCapabilities: LIST_CAPS }),
|
||||
);
|
||||
const { result } = renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
|
||||
expect(result.current.pagination?.pageIndex).toBe(0);
|
||||
|
||||
act(() => result.current.pagination?.goNext());
|
||||
@@ -534,11 +428,7 @@ describe('usePanelQuery', () => {
|
||||
it('stays defined and zero-paged for a non-raw (scalar) response', () => {
|
||||
withResponse({ data: { type: 'scalar', data: { results: [] } } });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.pagination).toBeDefined();
|
||||
expect(result.current.pagination?.canNext).toBe(false);
|
||||
@@ -547,11 +437,7 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('ignores a non-positive page size so paging never goes invalid', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: listPanel({}),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: LIST_CAPS,
|
||||
}),
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
act(() => result.current.pagination?.setPageSize(0));
|
||||
expect(result.current.pagination?.pageSize).toBe(25);
|
||||
@@ -570,26 +456,14 @@ describe('usePanelQuery', () => {
|
||||
|
||||
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
|
||||
withAutoRefreshDisabled(true);
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
|
||||
});
|
||||
|
||||
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
|
||||
withAutoRefreshDisabled(false);
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
}),
|
||||
);
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
queryReferencesAnyVariable,
|
||||
} from '../queryV5/getReferencedVariables';
|
||||
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
|
||||
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
|
||||
@@ -37,8 +38,6 @@ const DEFAULT_LIST_PAGE_SIZE = 25;
|
||||
export interface UsePanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** The panel kind's declared query capabilities — `panelDefinition.query`, or `DEFAULT_QUERY_CAPABILITIES` for a kind the registry doesn't resolve. */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
/**
|
||||
* Gate the fetch (default true). PanelV2 sets false for unregistered kinds to skip a wasted
|
||||
* call. The hook also auto-disables internally when the panel has no runnable queries.
|
||||
@@ -86,20 +85,21 @@ export interface UsePanelQueryResult {
|
||||
export function usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
queryCapabilities,
|
||||
enabled = true,
|
||||
time,
|
||||
}: UsePanelQueryArgs): UsePanelQueryResult {
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
const queries = panel.spec.queries;
|
||||
|
||||
// V1 parity: a query with an explicit `limit` shows without a server pager; without
|
||||
// one a paging kind fetches server-side at a user-selectable size.
|
||||
// V1 parity: a list query with an explicit `limit` shows without a server pager; without
|
||||
// one it pages server-side at a user-selectable size.
|
||||
const hasExplicitLimit = useMemo(
|
||||
() => !!getBuilderQueries(queries)[0]?.limit,
|
||||
[queries],
|
||||
);
|
||||
const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit;
|
||||
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
|
||||
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
|
||||
const [offset, setOffset] = useState(0);
|
||||
@@ -188,7 +188,7 @@ export function usePanelQuery({
|
||||
() =>
|
||||
buildQueryRangeRequest({
|
||||
queries,
|
||||
queryCapabilities,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
@@ -197,7 +197,7 @@ export function usePanelQuery({
|
||||
}),
|
||||
[
|
||||
queries,
|
||||
queryCapabilities,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {
|
||||
type DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
getBarStepIntervalSeconds,
|
||||
hasRunnableQueries,
|
||||
panelTypeToRequestType,
|
||||
toQueryEnvelopes,
|
||||
} from '../buildQueryRangeRequest';
|
||||
|
||||
@@ -41,47 +40,20 @@ function compositeQuery(
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const START_MS = 1_700_000_000_000;
|
||||
|
||||
// Capability blocks matching what each kind declares, so these tests exercise the
|
||||
// builder's response to the flags rather than the declarations themselves (those are
|
||||
// asserted against the registry in Panels/__tests__/capabilities.test.ts).
|
||||
const TIME_SERIES_CAPS = {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
};
|
||||
const BAR_CAPS = { ...TIME_SERIES_CAPS, bucketedStepInterval: true };
|
||||
const TABLE_CAPS = {
|
||||
...TIME_SERIES_CAPS,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: true,
|
||||
};
|
||||
const LIST_CAPS = {
|
||||
...TIME_SERIES_CAPS,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
listView: true,
|
||||
traceOperator: false,
|
||||
};
|
||||
|
||||
describe('requestType', () => {
|
||||
describe('panelTypeToRequestType', () => {
|
||||
it.each([
|
||||
Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
])('passes %s through from the declared capabilities', (requestType) => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
queryCapabilities: { ...TIME_SERIES_CAPS, requestType },
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
expect(request.requestType).toBe(requestType);
|
||||
[PANEL_TYPES.TIME_SERIES, 'time_series'],
|
||||
// HISTOGRAM and BAR bin client-side from time-series data; sending
|
||||
// 'distribution' would return a shape the renderers can't bin.
|
||||
[PANEL_TYPES.BAR, 'time_series'],
|
||||
[PANEL_TYPES.HISTOGRAM, 'time_series'],
|
||||
[PANEL_TYPES.TABLE, 'scalar'],
|
||||
[PANEL_TYPES.PIE, 'scalar'],
|
||||
[PANEL_TYPES.VALUE, 'scalar'],
|
||||
[PANEL_TYPES.LIST, 'raw'],
|
||||
[PANEL_TYPES.TRACE, 'trace'],
|
||||
])('%s → %s', (panelType, requestType) => {
|
||||
expect(panelTypeToRequestType(panelType)).toBe(requestType);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,7 +135,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('assembles the full request DTO', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -185,7 +157,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('sets formatTableResultForUI only for TABLE panels', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
queryCapabilities: TABLE_CAPS,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -195,7 +167,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('passes through fillGaps into formatOptions', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
fillGaps: true,
|
||||
@@ -206,7 +178,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('stamps offset/limit onto builder queries when pagination is given', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
|
||||
queryCapabilities: LIST_CAPS,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
pagination: { offset: 100, limit: 50 },
|
||||
@@ -226,7 +198,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('defaults a logs list with no order to timestamp desc + id tiebreaker', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
|
||||
queryCapabilities: LIST_CAPS,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -246,7 +218,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
signal: 'logs',
|
||||
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
|
||||
}),
|
||||
queryCapabilities: LIST_CAPS,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -266,7 +238,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
];
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
|
||||
queryCapabilities: LIST_CAPS,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -280,7 +252,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
|
||||
queryCapabilities: LIST_CAPS,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -293,7 +265,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
queryCapabilities: BAR_CAPS,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -308,7 +280,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('preserves a user-set stepInterval on BAR builder queries', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
|
||||
queryCapabilities: BAR_CAPS,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -321,7 +293,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
it('does not touch stepInterval for non-BAR panels', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
queryCapabilities: TIME_SERIES_CAPS,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
|
||||
@@ -7,12 +7,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
envelopesToQuery,
|
||||
fromPerses,
|
||||
panelTypeToRequestType,
|
||||
toPerses,
|
||||
} from '../persesQueryAdapters';
|
||||
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
|
||||
|
||||
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
|
||||
function bareQuery(
|
||||
@@ -26,23 +21,6 @@ function bareQuery(
|
||||
}
|
||||
|
||||
describe('persesQueryAdapters', () => {
|
||||
describe('panelTypeToRequestType', () => {
|
||||
it.each([
|
||||
[PANEL_TYPES.TIME_SERIES, 'time_series'],
|
||||
// HISTOGRAM and BAR bin client-side from time-series data; sending
|
||||
// 'distribution' would return a shape the renderers can't bin.
|
||||
[PANEL_TYPES.BAR, 'time_series'],
|
||||
[PANEL_TYPES.HISTOGRAM, 'time_series'],
|
||||
[PANEL_TYPES.TABLE, 'scalar'],
|
||||
[PANEL_TYPES.PIE, 'scalar'],
|
||||
[PANEL_TYPES.VALUE, 'scalar'],
|
||||
[PANEL_TYPES.LIST, 'raw'],
|
||||
[PANEL_TYPES.TRACE, 'trace'],
|
||||
])('%s → %s', (panelType, requestType) => {
|
||||
expect(panelTypeToRequestType(panelType)).toBe(requestType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromPerses', () => {
|
||||
it('returns a fresh metrics builder query for an empty panel', () => {
|
||||
const query = fromPerses([], PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
|
||||
// shared fields are read through this view with a localized cast at the envelope boundary.
|
||||
@@ -29,6 +29,31 @@ interface QuerySpecView {
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a V2 panel type to the V5 `requestType`. HISTOGRAM/BAR bin client-side from raw
|
||||
* time-series, so their request type is `time_series` (V1 parity).
|
||||
*/
|
||||
export function panelTypeToRequestType(
|
||||
panelType: PANEL_TYPES,
|
||||
): Querybuildertypesv5RequestTypeDTO {
|
||||
switch (panelType) {
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
case PANEL_TYPES.TRACE:
|
||||
return Querybuildertypesv5RequestTypeDTO.trace;
|
||||
default:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps the perses query into the V5 `compositeQuery.queries` list: a CompositeQuery passes
|
||||
* through verbatim, bare plugins wrap into one envelope. Top-level Formula/TraceOperator are
|
||||
@@ -214,13 +239,7 @@ function withPagination(
|
||||
|
||||
export interface BuildQueryRangeRequestArgs {
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
/**
|
||||
* The panel kind's declared query capabilities (`PanelDefinition.query`): request type,
|
||||
* result formatting, and the step-interval/order treatment. Passed in rather than looked up
|
||||
* by kind so this stays a leaf of the query layer — the panel registry carries every
|
||||
* renderer with it, which has no business in the data path.
|
||||
*/
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
panelType: PANEL_TYPES;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
@@ -239,12 +258,7 @@ export interface BuildQueryRangeRequestArgs {
|
||||
*/
|
||||
export function buildQueryRangeRequest({
|
||||
queries,
|
||||
queryCapabilities: {
|
||||
requestType,
|
||||
formatTableResultForUI,
|
||||
bucketedStepInterval,
|
||||
orderTiebreaker,
|
||||
},
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps = false,
|
||||
@@ -252,10 +266,10 @@ export function buildQueryRangeRequest({
|
||||
variables = {},
|
||||
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
let envelopes = toQueryEnvelopes(queries);
|
||||
if (bucketedStepInterval) {
|
||||
if (panelType === PANEL_TYPES.BAR) {
|
||||
envelopes = withBarStepInterval(envelopes, startMs, endMs);
|
||||
}
|
||||
if (orderTiebreaker) {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
envelopes = withListOrderTiebreaker(envelopes);
|
||||
}
|
||||
if (pagination) {
|
||||
@@ -266,10 +280,10 @@ export function buildQueryRangeRequest({
|
||||
schemaVersion: 'v1',
|
||||
start: startMs,
|
||||
end: endMs,
|
||||
requestType,
|
||||
requestType: panelTypeToRequestType(panelType),
|
||||
compositeQuery: { queries: envelopes },
|
||||
formatOptions: {
|
||||
formatTableResultForUI,
|
||||
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
|
||||
fillGaps,
|
||||
},
|
||||
variables,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
@@ -21,7 +20,10 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { toQueryEnvelopes } from './buildQueryRangeRequest';
|
||||
import {
|
||||
panelTypeToRequestType,
|
||||
toQueryEnvelopes,
|
||||
} from './buildQueryRangeRequest';
|
||||
|
||||
/**
|
||||
* Adapters between the V2 perses query shape and the V1 `Query` the shared query
|
||||
@@ -88,33 +90,6 @@ export function deriveQueryType(
|
||||
return EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a legacy panel type to the V5 `requestType`. Lives on this side of the V1 boundary
|
||||
* because only the V1 pivot still speaks `PANEL_TYPES` — V2 panels read `requestType` off
|
||||
* their kind's declared query capabilities instead. BAR/HISTOGRAM bin client-side from a raw
|
||||
* time series, so they request `time_series` (V1 parity).
|
||||
*/
|
||||
export function panelTypeToRequestType(
|
||||
panelType: PANEL_TYPES,
|
||||
): Querybuildertypesv5RequestTypeDTO {
|
||||
switch (panelType) {
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
case PANEL_TYPES.TRACE:
|
||||
return Querybuildertypesv5RequestTypeDTO.trace;
|
||||
default:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
|
||||
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a
|
||||
|
||||
@@ -62,10 +62,7 @@ export function buildNewPanelSeed(
|
||||
if (!isExplorerExport || !compositeQuery) {
|
||||
return {
|
||||
kind: requestedKind,
|
||||
queries: buildDefaultQueries(
|
||||
requestedKind,
|
||||
getPanelDefinition(requestedKind).query,
|
||||
),
|
||||
queries: buildDefaultQueries(requestedKind),
|
||||
pluginSpec: buildPluginSpec(getPanelDefinition(requestedKind).sections),
|
||||
};
|
||||
}
|
||||
@@ -74,10 +71,7 @@ export function buildNewPanelSeed(
|
||||
const pluginSpec = buildPluginSpec(getPanelDefinition(kind).sections);
|
||||
|
||||
const converted = toPerses(compositeQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
|
||||
const queries =
|
||||
converted.length > 0
|
||||
? converted
|
||||
: buildDefaultQueries(kind, getPanelDefinition(kind).query);
|
||||
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
|
||||
|
||||
// Explorers put the single `unit` on the query itself, not the panel spec.
|
||||
if (compositeQuery.unit && kindSupportsUnit(kind)) {
|
||||
|
||||
@@ -40,7 +40,6 @@ function PublicPanel({
|
||||
const { data, isFetching, isPreviousData, error, refetch } =
|
||||
usePublicPanelQuery({
|
||||
panel,
|
||||
queryCapabilities: panelDefinition.query,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
@@ -45,17 +42,6 @@ const panel = {
|
||||
|
||||
const args = {
|
||||
panel,
|
||||
// What TimeSeries declares; passed in rather than resolved from the registry, which
|
||||
// would pull every panel renderer into this suite.
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
listView: false,
|
||||
traceOperator: true,
|
||||
},
|
||||
panelKey: 'panel-1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
|
||||
@@ -3,9 +3,10 @@ import type {
|
||||
DashboardtypesPanelDTO,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
|
||||
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
@@ -20,8 +21,6 @@ import { useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
export interface UsePublicPanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel kind's declared query capabilities — `panelDefinition.query`. */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
@@ -53,13 +52,15 @@ export interface UsePublicPanelQueryResult {
|
||||
*/
|
||||
export function usePublicPanelQuery({
|
||||
panel,
|
||||
queryCapabilities,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled = true,
|
||||
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
const { queries } = panel.spec;
|
||||
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
@@ -76,13 +77,13 @@ export function usePublicPanelQuery({
|
||||
() =>
|
||||
buildQueryRangeRequest({
|
||||
queries,
|
||||
queryCapabilities,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
variables: {},
|
||||
}),
|
||||
[queries, queryCapabilities, startMs, endMs, fillGaps],
|
||||
[queries, panelType, startMs, endMs, fillGaps],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
|
||||
|
||||
@@ -42,7 +42,7 @@ function SpanPercentilePanel({
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange,
|
||||
showResourceAttributesSelector,
|
||||
toggleResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
spanResourceAttributes,
|
||||
@@ -72,7 +72,9 @@ function SpanPercentilePanel({
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={toggleResourceAttributesSelector}
|
||||
onClick={(): void =>
|
||||
setShowResourceAttributesSelector(!showResourceAttributesSelector)
|
||||
}
|
||||
prefix={
|
||||
showResourceAttributesSelector ? <Check size={16} /> : <Plus size={16} />
|
||||
}
|
||||
|
||||
@@ -8,11 +8,6 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import dayjs from 'dayjs';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
} from 'pages/TraceDetailsV3/events';
|
||||
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export interface IResourceAttribute {
|
||||
@@ -42,7 +37,7 @@ export interface UseSpanPercentileReturn {
|
||||
selectedTimeRange: number;
|
||||
setSelectedTimeRange: (range: number) => void;
|
||||
showResourceAttributesSelector: boolean;
|
||||
toggleResourceAttributesSelector: () => void;
|
||||
setShowResourceAttributesSelector: (show: boolean) => void;
|
||||
resourceAttributesSearchQuery: string;
|
||||
setResourceAttributesSearchQuery: (query: string) => void;
|
||||
spanResourceAttributes: IResourceAttribute[];
|
||||
@@ -81,8 +76,6 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
|
||||
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
|
||||
useClickOutside({
|
||||
ref: resourceAttributesSelectorRef,
|
||||
onClickOutside: () => {
|
||||
@@ -264,12 +257,6 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
|
||||
const handleResourceAttributeChange = useCallback(
|
||||
(key: string, value: string, isSelected: boolean): void => {
|
||||
logTraceEvent(TraceDetailEvents.SpanPercentileAttributeChanged, {
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
[TraceDetailEventKeys.ResourceAttributeKey]: key,
|
||||
[TraceDetailEventKeys.Selected]: isSelected,
|
||||
});
|
||||
|
||||
updateSpanResourceAttributes((prev) =>
|
||||
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
|
||||
);
|
||||
@@ -284,7 +271,7 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
setShouldFetchData(true);
|
||||
setShouldUpdateUserPreference(true);
|
||||
},
|
||||
[selectedResourceAttributes, logTraceEvent, selectedSpan.span_id],
|
||||
[selectedResourceAttributes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -306,37 +293,12 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
'ms',
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => {
|
||||
const nextOpen = !isOpen;
|
||||
setIsOpen(nextOpen);
|
||||
logTraceEvent(TraceDetailEvents.SpanPercentileToggled, {
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
[TraceDetailEventKeys.Open]: nextOpen,
|
||||
[TraceDetailEventKeys.PercentileValue]: percentileValue,
|
||||
});
|
||||
}, [isOpen, logTraceEvent, selectedSpan.span_id, percentileValue]);
|
||||
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
const toggleResourceAttributesSelector = useCallback(() => {
|
||||
const nextOpen = !showResourceAttributesSelector;
|
||||
setShowResourceAttributesSelector(nextOpen);
|
||||
logTraceEvent(TraceDetailEvents.SpanPercentileAttributesSelectorToggled, {
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
[TraceDetailEventKeys.Open]: nextOpen,
|
||||
});
|
||||
}, [showResourceAttributesSelector, logTraceEvent, selectedSpan.span_id]);
|
||||
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(range: number): void => {
|
||||
logTraceEvent(TraceDetailEvents.SpanPercentileTimeRangeChanged, {
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
[TraceDetailEventKeys.From]: selectedTimeRange,
|
||||
[TraceDetailEventKeys.To]: range,
|
||||
});
|
||||
setShouldFetchData(true);
|
||||
setSelectedTimeRange(range);
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id, selectedTimeRange],
|
||||
);
|
||||
const handleTimeRangeChange = useCallback((range: number): void => {
|
||||
setShouldFetchData(true);
|
||||
setSelectedTimeRange(range);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
@@ -350,7 +312,7 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange: handleTimeRangeChange,
|
||||
showResourceAttributesSelector,
|
||||
toggleResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
spanResourceAttributes,
|
||||
|
||||
@@ -8,10 +8,6 @@ export enum TraceDetailEvents {
|
||||
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
|
||||
DownloadTriggered = 'Trace Detail: Download triggered',
|
||||
DownloadCancelled = 'Trace Detail: Download cancelled',
|
||||
SpanPercentileToggled = 'Trace Detail: Span percentile toggled',
|
||||
SpanPercentileTimeRangeChanged = 'Trace Detail: Span percentile time range changed',
|
||||
SpanPercentileAttributesSelectorToggled = 'Trace Detail: Span percentile attributes selector toggled',
|
||||
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
@@ -40,10 +36,6 @@ export enum TraceDetailEventKeys {
|
||||
SpanId = 'spanId',
|
||||
// Download triggered (reuses TotalSpansCount for trace size)
|
||||
Format = 'format',
|
||||
// Span percentile (reuses Open, SpanId, From, To)
|
||||
PercentileValue = 'percentileValue',
|
||||
ResourceAttributeKey = 'resourceAttributeKey',
|
||||
Selected = 'selected',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
border: none;
|
||||
min-height: 36px;
|
||||
height: 36px;
|
||||
.ant-select-selection-search-input {
|
||||
min-width: max-content !important;
|
||||
max-width: 100% !important;
|
||||
@@ -191,7 +191,7 @@
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
min-height: 36px;
|
||||
height: 36px;
|
||||
border-color: var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
border-radius: 0;
|
||||
|
||||
@@ -30,10 +30,6 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.translate-safe {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Respect user's reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.6
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.5
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.6 h1:njgRLxQz/pE16ZO1MSjWadIzabwqsjDMMX8RR5Dbv7Y=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.6/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.5 h1:LCA23yAA4GgF73PoYXb67yzCdC4sXsj4geQz1Oij3U8=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.5/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
|
||||
@@ -4135,6 +4135,10 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
|
||||
return readRowsForTimeSeriesResult(rows, vars, columnNames, countOfNumberCols)
|
||||
}
|
||||
|
||||
func isJSONColumn(columnType driver.ColumnType) bool {
|
||||
return strings.HasPrefix(strings.ToUpper(columnType.DatabaseTypeName()), "JSON")
|
||||
}
|
||||
|
||||
// GetListResultV3 runs the query and returns list of rows
|
||||
func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([]*v3.Row, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
@@ -4159,6 +4163,12 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
|
||||
for rows.Next() {
|
||||
var vars = make([]interface{}, len(columnTypes))
|
||||
for i := range columnTypes {
|
||||
if isJSONColumn(columnTypes[i]) {
|
||||
// the driver fails to decode JSON into native Go values, so it is read as raw bytes
|
||||
var raw []byte
|
||||
vars[i] = &raw
|
||||
continue
|
||||
}
|
||||
vars[i] = reflect.New(columnTypes[i].ScanType()).Interface()
|
||||
}
|
||||
if err := rows.Scan(vars...); err != nil {
|
||||
@@ -4167,7 +4177,17 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
|
||||
row := map[string]interface{}{}
|
||||
var t time.Time
|
||||
for idx, v := range vars {
|
||||
if columnNames[idx] == "timestamp" {
|
||||
if isJSONColumn(columnTypes[idx]) {
|
||||
raw, ok := v.(*[]byte)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var value map[string]interface{}
|
||||
if err := json.Unmarshal(*raw, &value); err != nil {
|
||||
return nil, errors.New(err.Error())
|
||||
}
|
||||
row[columnNames[idx]] = value
|
||||
} else if columnNames[idx] == "timestamp" {
|
||||
switch v := v.(type) {
|
||||
case *uint64:
|
||||
t = time.Unix(0, int64(*v))
|
||||
|
||||
@@ -205,9 +205,8 @@ const (
|
||||
"scope_string "
|
||||
LogsSQLSelectV2 = logsSQLSelectV2Head + "body, " + logsSQLSelectV2Tail
|
||||
// Orgs on JSON bodies keep the body in body_v2 and have the body column written empty.
|
||||
// Stringified because filters emit a bare `body`, which ClickHouse resolves to this alias:
|
||||
// as JSON it fails every string comparison, as String it matches against the body text.
|
||||
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "toString(body_v2) as body, " + logsSQLSelectV2Tail
|
||||
// Selected as JSON so the response carries the same body object v5 returns.
|
||||
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "body_v2 as body, " + logsSQLSelectV2Tail
|
||||
TracesExplorerViewSQLSelectWithSubQuery = "(SELECT traceID, durationNano, " +
|
||||
"serviceName, name FROM %s.%s WHERE parentSpanID = '' AND %s ORDER BY durationNano DESC LIMIT 1 BY traceID"
|
||||
TracesExplorerViewSQLSelectBeforeSubQuery = "SELECT subQuery.serviceName as `subQuery.serviceName`, subQuery.name as `subQuery.name`, count() AS " +
|
||||
|
||||
@@ -25,10 +25,6 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
|
||||
{"GlobalNotIn", "SELECT a FROM t WHERE a GLOBAL NOT IN (SELECT b FROM t2)"},
|
||||
{"Union", "SELECT * FROM t UNION ALL SELECT * FROM t2"},
|
||||
{"Intersect", "SELECT * FROM t INTERSECT SELECT * FROM t2"},
|
||||
// A parenthesised left operand of a set operator. https://github.com/AfterShip/clickhouse-sql-parser/pull/312
|
||||
{"ParenthesisedUnionLeftOperand", "SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))"},
|
||||
{"ParenthesisedExceptLeftOperand", "SELECT a FROM ((SELECT 1 AS a) EXCEPT (SELECT 2 AS a))"},
|
||||
{"ParenthesisedUnionLeftOperandAtStatementLevel", "(SELECT 1 AS a) UNION ALL (SELECT 2 AS a)"},
|
||||
{"WindowFunction", "SELECT sum(v) OVER (PARTITION BY a ORDER BY t) FROM t"},
|
||||
{"UnrelatedSetting", "SELECT * FROM t SETTINGS max_threads = 4"},
|
||||
{"TerminatedBlockComment", "SELECT /* keep me */ count() FROM t"},
|
||||
@@ -187,10 +183,12 @@ func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
|
||||
query string
|
||||
expectedCode errors.Code
|
||||
}{
|
||||
// The left operand commits the parser to a subquery, leaving the operator nowhere to bind. Parenthesising only the right operand is fine.
|
||||
{"ParenthesisedUnionLeftOperand", "SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))", CodeClickHouseSQLUnparseable},
|
||||
{"ParenthesisedExceptLeftOperand", "SELECT a FROM ((SELECT 1 AS a) EXCEPT (SELECT 2 AS a))", CodeClickHouseSQLUnparseable},
|
||||
{"ParenthesisedUnionLeftOperandAtStatementLevel", "(SELECT 1 AS a) UNION ALL (SELECT 2 AS a)", CodeClickHouseSQLUnparseable},
|
||||
// The one keyword PR 305 left behind, because ON also opens a join condition.
|
||||
{"UnquotedOnAsColumnName", "SELECT on + 1 FROM t", CodeClickHouseSQLUnparseable},
|
||||
// ClickHouse accepts NULLS FIRST|LAST as an ORDER BY modifier; the parser's grammar has no rule for it.
|
||||
{"OrderByNullsLast", "SELECT x FROM t ORDER BY x DESC NULLS LAST", CodeClickHouseSQLUnparseable},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -1011,8 +1011,8 @@ func TestStmtBuilderBodyField(t *testing.T) {
|
||||
},
|
||||
enableUseJSONBody: false,
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE body = ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (body = ? AND LOWER(body) = LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"", "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
@@ -314,6 +314,14 @@ func (c *conditionBuilder) conditionForResolvedKey(
|
||||
// make use of case insensitive index for body
|
||||
if fieldExpression == "body" || fieldExpression == messageSubColumn {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
// Bloom filters index lower(body), not the column; `=` still decides the row.
|
||||
if _, ok := value.(string); ok && fieldExpression == LogsV2BodyColumn {
|
||||
return sb.And(
|
||||
sb.E(fieldExpression, value),
|
||||
fmt.Sprintf("LOWER(%s) = LOWER(%s)", fieldExpression, sb.Var(value)),
|
||||
), nil
|
||||
}
|
||||
case qbtypes.FilterOperatorLike:
|
||||
return sb.ILike(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorNotLike:
|
||||
|
||||
@@ -168,9 +168,9 @@ func TestConditionFor(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextLog,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "error message",
|
||||
expectedSQL: "body = ?",
|
||||
expectedArgs: []any{"error message"},
|
||||
value: "Error Message",
|
||||
expectedSQL: "(body = ? AND LOWER(body) = LOWER(?))",
|
||||
expectedArgs: []any{"Error Message", "Error Message"},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
@@ -619,8 +619,8 @@ func TestConditionForMultipleKeys(t *testing.T) {
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "error message",
|
||||
expectedSQL: "body = ? AND severity_text = ?",
|
||||
expectedArgs: []any{"error message", "error message"},
|
||||
expectedSQL: "(body = ? AND LOWER(body) = LOWER(?)) AND severity_text = ?",
|
||||
expectedArgs: []any{"error message", "error message", "error message"},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
@@ -906,8 +906,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// IN on the body column routes each value back through the `=` path; the SQL it produces
|
||||
// must stay what the shared IN handling produced before, including for a mixed-type list.
|
||||
// IN on the body column routes each value back through the `=` path, so every arm picks up
|
||||
// the lower(body) companion — including the values a mixed-type list stringifies.
|
||||
func TestConditionForBodyIn(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -918,14 +918,14 @@ func TestConditionForBodyIn(t *testing.T) {
|
||||
{
|
||||
name: "strings",
|
||||
values: []any{"alpha", "beta"},
|
||||
expectedSQL: "(body = ? OR body = ?)",
|
||||
expectedArgs: []any{"alpha", "beta"},
|
||||
expectedSQL: "((body = ? AND LOWER(body) = LOWER(?)) OR (body = ? AND LOWER(body) = LOWER(?)))",
|
||||
expectedArgs: []any{"alpha", "alpha", "beta", "beta"},
|
||||
},
|
||||
{
|
||||
name: "mixed types are stringified before they reach the column",
|
||||
values: []any{"alpha", float64(1), true},
|
||||
expectedSQL: "(body = ? OR body = ? OR body = ?)",
|
||||
expectedArgs: []any{"alpha", "1", "true"},
|
||||
expectedSQL: "((body = ? AND LOWER(body) = LOWER(?)) OR (body = ? AND LOWER(body) = LOWER(?)) OR (body = ? AND LOWER(body) = LOWER(?)))",
|
||||
expectedArgs: []any{"alpha", "alpha", "1", "1", "true", "true"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import build_order_by, build_raw_query, get_rows, make_query_request
|
||||
|
||||
LOWER = "alpha"
|
||||
UPPER = "ALPHA"
|
||||
PLAIN = "beta"
|
||||
NON_ASCII = "Mixed CASE Ünïcode"
|
||||
SLASH = "GET /api/v1/users"
|
||||
SUPERSTRING = "GET /api/v1/users/42"
|
||||
QUOTE = 'say "hi" now'
|
||||
BACKSLASH = "C:\\tmp\\log"
|
||||
LIKE_META = "100% _off"
|
||||
TAB = "tab\there"
|
||||
CTRL = "ctrl\x01here"
|
||||
|
||||
BODIES = [LOWER, UPPER, PLAIN, NON_ASCII, SLASH, SUPERSTRING, QUOTE, BACKSLASH, LIKE_META, TAB, CTRL]
|
||||
|
||||
|
||||
# querierlogs/16_body_equality.py with use_json_body on: `body` resolves to body_v2.message,
|
||||
# which the lower(body) companion skips, and the same expressions must still answer alike.
|
||||
@pytest.mark.parametrize(
|
||||
"expression,expected_bodies",
|
||||
[
|
||||
pytest.param(f"body = '{LOWER}'", {LOWER}, id="equality_exact"),
|
||||
pytest.param(f"body = '{UPPER}'", {UPPER}, id="equality_other_case"),
|
||||
pytest.param("body = 'Alpha'", set(), id="equality_case_must_match"),
|
||||
pytest.param(f"body = '{NON_ASCII}'", {NON_ASCII}, id="equality_non_ascii"),
|
||||
pytest.param("body = 'gamma'", set(), id="equality_no_match"),
|
||||
pytest.param(f"body = '{SLASH}'", {SLASH}, id="equality_slash"),
|
||||
pytest.param("body = 'say \"hi\" now'", {QUOTE}, id="equality_quote"),
|
||||
pytest.param(r"body = 'C:\\tmp\\log'", {BACKSLASH}, id="equality_backslash"),
|
||||
pytest.param(f"body = '{LIKE_META}'", {LIKE_META}, id="equality_like_metacharacters"),
|
||||
pytest.param("body = 'tab\there'", {TAB}, id="equality_tab"),
|
||||
pytest.param("body = 'ctrl\x01here'", {CTRL}, id="equality_control_char"),
|
||||
pytest.param("body = 'GET /api/v1'", set(), id="equality_prefix_does_not_match"),
|
||||
pytest.param(f"body IN ('{LOWER}', '{PLAIN}')", {LOWER, PLAIN}, id="in_excludes_other_case"),
|
||||
pytest.param(f"body IN ('{SLASH}', '{LIKE_META}')", {SLASH, LIKE_META}, id="in_escaped_values"),
|
||||
pytest.param(f"body NOT IN ('{LOWER}', '{UPPER}')", set(BODIES) - {LOWER, UPPER}, id="not_in"),
|
||||
],
|
||||
)
|
||||
def test_logs_body_equality_json(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
expression: str,
|
||||
expected_bodies: set[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
resources={"service.name": "api"},
|
||||
body=body,
|
||||
)
|
||||
for i, body in enumerate(BODIES)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"logs",
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
|
||||
limit=100,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "success"
|
||||
# body_v2 comes back parsed; a plain-string body is {"message": <body>}.
|
||||
assert {row["data"]["body"]["message"] for row in get_rows(response)} == expected_bodies
|
||||
92
tests/integration/tests/querierlogs/16_body_equality.py
Normal file
92
tests/integration/tests/querierlogs/16_body_equality.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import build_order_by, build_raw_query, get_column_data_from_response, make_query_request
|
||||
|
||||
LOWER = "alpha"
|
||||
UPPER = "ALPHA"
|
||||
PLAIN = "beta"
|
||||
NON_ASCII = "Mixed CASE Ünïcode"
|
||||
SLASH = "GET /api/v1/users"
|
||||
SUPERSTRING = "GET /api/v1/users/42"
|
||||
QUOTE = 'say "hi" now'
|
||||
BACKSLASH = "C:\\tmp\\log"
|
||||
LIKE_META = "100% _off"
|
||||
TAB = "tab\there"
|
||||
CTRL = "ctrl\x01here"
|
||||
|
||||
BODIES = [LOWER, UPPER, PLAIN, NON_ASCII, SLASH, SUPERSTRING, QUOTE, BACKSLASH, LIKE_META, TAB, CTRL]
|
||||
|
||||
|
||||
# `body = ?` carries a case-insensitive LOWER(body) companion for the bloom filters, so a
|
||||
# body differing only in case must still not come back.
|
||||
@pytest.mark.parametrize(
|
||||
"expression,expected_bodies",
|
||||
[
|
||||
pytest.param(f"body = '{LOWER}'", {LOWER}, id="equality_exact"),
|
||||
pytest.param(f"body = '{UPPER}'", {UPPER}, id="equality_other_case"),
|
||||
pytest.param("body = 'Alpha'", set(), id="equality_case_must_match"),
|
||||
pytest.param(f"body = '{NON_ASCII}'", {NON_ASCII}, id="equality_non_ascii"),
|
||||
pytest.param("body = ''", set(), id="equality_empty"),
|
||||
pytest.param("body = 'gamma'", set(), id="equality_no_match"),
|
||||
# the companion is a LIKE-free equality, so none of these are metacharacters to it
|
||||
pytest.param(f"body = '{SLASH}'", {SLASH}, id="equality_slash"),
|
||||
pytest.param("body = 'say \"hi\" now'", {QUOTE}, id="equality_quote"),
|
||||
pytest.param(r"body = 'C:\\tmp\\log'", {BACKSLASH}, id="equality_backslash"),
|
||||
pytest.param(f"body = '{LIKE_META}'", {LIKE_META}, id="equality_like_metacharacters"),
|
||||
pytest.param("body = 'tab\there'", {TAB}, id="equality_tab"),
|
||||
pytest.param("body = 'ctrl\x01here'", {CTRL}, id="equality_control_char"),
|
||||
# a prefix of another body must not match it
|
||||
pytest.param("body = 'GET /api/v1'", set(), id="equality_prefix_does_not_match"),
|
||||
pytest.param(f"body IN ('{LOWER}', '{PLAIN}')", {LOWER, PLAIN}, id="in_excludes_other_case"),
|
||||
pytest.param(f"body IN ('{SLASH}', '{LIKE_META}')", {SLASH, LIKE_META}, id="in_escaped_values"),
|
||||
pytest.param(f"body NOT IN ('{LOWER}', '{UPPER}')", set(BODIES) - {LOWER, UPPER}, id="not_in"),
|
||||
],
|
||||
)
|
||||
def test_logs_body_equality(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
expression: str,
|
||||
expected_bodies: set[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
resources={"service.name": "api"},
|
||||
body=body,
|
||||
)
|
||||
for i, body in enumerate(BODIES)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"logs",
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
|
||||
limit=100,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["status"] == "success"
|
||||
assert set(get_column_data_from_response(response.json(), "body")) == expected_bodies
|
||||
Reference in New Issue
Block a user