Compare commits

...

10 Commits

Author SHA1 Message Date
Abhi Kumar
e34e79fb65 feat(dashboards): add the Text panel's Markdown renderer
MarkdownContent renders an authored body. It is panel-local rather than shared
because the shared MarkdownRenderer enables rehype-raw, which is safe only for
the trusted content it was built for; this one never gets it, so raw HTML in a
user-authored body renders as text with no dangerouslySetInnerHTML on the path.
A rejected `javascript:` href drops the anchor rather than rendering
react-markdown's inert stand-in.

Its stylesheet reverts the subtree to user-agent styling so no global rule
reaches the rendered body. Custom properties survive `all`, so theming still
flows in, as does `text-align`, which the panel's presentation options will set
on an ancestor. Injected UI islands opt out through `[data-md-ui]`, matched
inside `:where()` so the exemption adds no specificity of its own.

Fenced blocks highlight with Prism at `useInlineStyles: false`, keeping the
token palette on design tokens, and load their language per fence. Each block
carries the shared periscope copy button, revealed on hover or focus, copying
the source exactly as fenced.

jest.config gains remark-gfm and its ESM-only dependencies; nothing had
exercised the plugin under jest before.
2026-09-04 11:19:33 +05:30
Abhi Kumar
9bcded3739 feat(dashboards): add a Markdown editor for panel authoring
The authoring surface that replaces the query-builder pane for query-less panel
kinds: a formatting toolbar over a CodeMirror document, a searchable
insert-variable menu, and a caret/character-count status bar.

Toolbar commands are pure snapshot-to-snapshot transforms with no CodeMirror
coupling, so a new action is one registry entry plus an icon. Markdown colouring
is a decoration pass rather than a grammar, which keeps it on the CodeMirror
packages already bundled instead of pulling in a language mode for what the
renderer parses for real anyway.

The document is uncontrolled, as in QuerySearch. A `value` prop reaching
CodeMirror lets a stale echo replace the document mid-keystroke and reset the
caret, so the seed runs from an `isEditorReady`-gated effect instead.

Nothing imports this yet; the Text panel kind wires it up in a follow-up.
2026-09-04 11:19:33 +05:30
Abhi Kumar
c71e6c2e40 chore: plottag minor change 2026-09-04 00:54:47 +05:30
Abhi Kumar
a2dd02d9c0 refactor(dashboards-v2): share the chart chrome between per-kind config args
- BuildTimeSeriesConfigArgs / BuildBarChartConfigArgs / BuildHistogramConfigArgs
  restated the fields they hand straight to buildBaseConfig; they now extend a
  Pick of BuildBaseConfigArgs. The spec-derived arms stay out.
- Document AxisProps.space alongside the rest of the axis fields.
- data-panel-visible said "false" for a panel with no visibility observer (View
  modal, editor preview) while it was treated as on screen for fetching.
2026-09-04 00:27:47 +05:30
Abhi Kumar
872b5696e5 chore: pr review changes 2026-09-04 00:27:47 +05:30
Abhi Kumar
c4fcf4d29a feat(dashboards-v2): report panelKind on panel analytics events
Panel events identified the panel only by its legacy panel type, which
cannot tell apart two kinds that map onto the same one — so a newly added
kind is indistinguishable from the kind it shares a type with.

Adds panelKind alongside the existing panelType on all seven events (no
data, clone, delete, move, CSV export, drilldown opened, create alert).
Additive on purpose: existing reports keep resolving.

Assisted-by: Claude Opus 5
2026-09-04 00:27:47 +05:30
Abhi Kumar
1cfbe29e8c refactor(dashboards-v2): drive the query path and builder mode off the declarations
buildQueryRangeRequest now takes the kind's declared query capabilities
instead of a legacy panel type, so the request type, table formatting, bar
step interval and list order tiebreaker all come from the kind itself. The
editor asks the same declarations whether the query builder runs in
list-view mode, offers a trace operator, shows the plot-mode chip, or seeds
a default query, rather than testing "is this the List panel?" in four
places.

The capabilities are passed in rather than looked up by kind: the panel
registry carries every renderer with it, which has no business in the data
path — importing it there pulls the app's API client into any test that
touches the request builder. The call sites already resolve the definition,
so threading it costs nothing. PlotTag takes isListView instead of a panel
type, so a presentational component no longer needs the enum at all.

panelTypeToRequestType moves to persesQueryAdapters, the V1 Query pivot
that is now its only caller — the legacy switch belongs on the V1 side of
the boundary rather than in the middle of the V5 request builder. The
shared QueryBuilderV2 provider keeps its legacy panelType prop: that is
state inside the shared provider, read by its subcomponents, and out of
scope here.

Assisted-by: Claude Opus 5
2026-09-04 00:27:47 +05:30
Abhi Kumar
f496da2a25 refactor(dashboards-v2): declare per-kind query capabilities
Each panel kind now states how its query behaves — request type, result
formatting, step-interval and order treatment, paging, whether it is
authored as a list view, and whether it offers a trace operator.

These are the questions V2 answered by comparing against the legacy
PANEL_TYPES enum. Declaring them per kind means the compiler asks for an
answer when a kind is added, instead of the kind silently falling through
someone else's switch. The expectations are an exhaustive Record over
PanelKind, so a new kind cannot ship without stating its request shape.

getPanelDefinition also stops lying. It was typed to return a definition
for any PanelKind, but the registry only holds the kinds this build
registers — a dashboard spec written by a newer SigNoz names one it has
never heard of, and callers coped by truthiness-checking a value the type
said could not be falsy. An unregistered kind now resolves to
UNSUPPORTED_PANEL, which declares nothing and renders as unsupported, so
callers read a definition's fields directly and such a panel says why it is
blank instead of leaving a hole in the layout. Whether a kind can be
rendered at all becomes its own question: isPanelKindSupported.

Assisted-by: Claude Opus 5
2026-09-04 00:27:47 +05:30
Abhi Kumar
564fcbc0a7 refactor(charts): declare the time axis instead of inferring it from a panel type
The uPlotV2 axis builder decided X-axis date formatting by testing the
panel type against a hardcoded [TIME_SERIES, BAR] list. A chart that plots
time but is not one of those two silently lost its time-formatted ticks —
no type error, no failing test, just wrong-looking ticks.

Axis props now take isTimeAxis and each caller states it: the three V2
kinds through the shared base config (histogram passes false — its X axis
is buckets), and the Meter Explorer, K8s metrics and V1 shared config
builders directly.

Assisted-by: Claude Opus 5
2026-09-04 00:27:47 +05:30
Abhi Kumar
a75859aa2a chore(dashboards-v2): remove the unused ViewPanelQueryBuilder
The View modal renders PanelEditorQueryBuilder; this component had no
importers and referenced a stylesheet class that no longer exists.

Assisted-by: Claude Opus 5
2026-09-04 00:27:47 +05:30
74 changed files with 3551 additions and 403 deletions

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { CursorPosition } from './types';
import styles from './MarkdownEditor.module.scss';
interface EditorStatusBarProps {
cursor: CursorPosition;
length: number;
maxLength: number;
hint?: ReactNode;
}
function EditorStatusBar({
cursor,
length,
maxLength,
hint,
}: EditorStatusBarProps): JSX.Element {
const isOverLimit = length > maxLength;
return (
<div className={styles.statusBar} data-testid="markdown-editor-status">
<Typography.Text className={styles.statusPosition}>
{`Ln ${cursor.line}, Col ${cursor.column}`}
<span className={styles.statusSeparator}>·</span>
<span
className={cx(styles.statusCount, {
[styles.statusCountOverLimit]: isOverLimit,
})}
data-testid="markdown-editor-char-count"
>
{isOverLimit
? `${length} / ${maxLength} chars`
: `${length} chars`}
</span>
</Typography.Text>
{hint && (
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
)}
</div>
);
}
export default EditorStatusBar;

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import {
Bold,
CodeXml,
Heading,
Italic,
Link,
List,
ListOrdered,
Table,
Type,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import InsertVariableMenu from './InsertVariableMenu';
import MarkdownHelp from './MarkdownHelp';
import type { EditorCommand, EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
const COMMAND_ICONS: Record<string, ReactNode> = {
heading: <Heading size={14} />,
bold: <Bold size={14} />,
italic: <Italic size={14} />,
'bulleted-list': <List size={14} />,
'numbered-list': <ListOrdered size={14} />,
link: <Link size={14} />,
code: <CodeXml size={14} />,
table: <Table size={14} />,
};
interface EditorToolbarProps {
formatLabel: string;
commands: EditorCommand[];
onRunCommand: (command: EditorCommand) => void;
variables: EditorVariable[];
onInsertVariable: (name: string) => void;
disabled: boolean;
extra?: ReactNode;
}
function EditorToolbar({
formatLabel,
commands,
onRunCommand,
variables,
onInsertVariable,
disabled,
extra,
}: EditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
<span className={styles.formatChip}>
<Type size={14} />
<Typography.Text className={styles.formatLabel}>
{formatLabel}
</Typography.Text>
</span>
<span className={styles.toolbarDivider} />
<div className={styles.commands}>
{commands.map((command) => (
<TooltipSimple key={command.id} title={command.label}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
disabled={disabled}
aria-label={command.label}
data-testid={`markdown-command-${command.id}`}
onClick={(): void => onRunCommand(command)}
>
{COMMAND_ICONS[command.id]}
</Button>
</TooltipSimple>
))}
</div>
<div className={styles.toolbarEnd}>
{extra}
<InsertVariableMenu
variables={variables}
onSelect={onInsertVariable}
disabled={disabled}
/>
<MarkdownHelp />
</div>
</div>
);
}
export default EditorToolbar;

View File

@@ -0,0 +1,91 @@
import { useMemo, useState } from 'react';
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
interface InsertVariableMenuProps {
variables: EditorVariable[];
/** Receives the variable name; the caller decides the token syntax. */
onSelect: (name: string) => void;
disabled: boolean;
}
function toMenuItems(
variables: EditorVariable[],
onSelect: (name: string) => void,
): MenuItem[] {
return variables.map((variable) => ({
key: variable.name,
label: (
<span
className={styles.variableRow}
data-testid={`markdown-variable-${variable.name}`}
>
<span className={styles.variableName}>{`$${variable.name}`}</span>
{variable.badge && (
<span className={styles.variableBadge}>{variable.badge}</span>
)}
</span>
),
onClick: (): void => onSelect(variable.name),
}));
}
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
function InsertVariableMenu({
variables,
onSelect,
disabled,
}: InsertVariableMenuProps): JSX.Element | null {
const [search, setSearch] = useState('');
const matches = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
: variables;
}, [variables, search]);
const items = useMemo(
() => toMenuItems(matches, onSelect),
[matches, onSelect],
);
if (variables.length === 0) {
return null;
}
return (
<DropdownMenuSimple
className={styles.variableMenu}
menu={{
items,
search: {
placeholder: 'Search variables',
searchIcon: <Search size={14} />,
onSearchChange: setSearch,
},
}}
>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
disabled={disabled}
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
suffix={<ChevronDown size={14} />}
className={styles.insertVariable}
data-testid="markdown-insert-variable"
>
Insert variable
</Button>
</DropdownMenuSimple>
);
}
export default InsertVariableMenu;

View File

@@ -0,0 +1,253 @@
@use '../../styles/scrollbar' as *;
.container {
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
--md-syntax-heading: var(--text-vanilla-100);
--md-syntax-strong: var(--text-vanilla-100);
--md-syntax-emphasis: var(--text-vanilla-300);
--md-syntax-quote: var(--text-vanilla-400);
--md-syntax-marker: var(--text-robin-300);
--md-syntax-code: var(--text-forest-400);
--md-syntax-link: var(--text-robin-400);
--md-syntax-variable: var(--text-amber-400);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--l1-background);
}
:global(body.lightMode) .container {
--md-syntax-heading: var(--text-ink-400);
--md-syntax-strong: var(--text-ink-400);
--md-syntax-emphasis: var(--text-ink-200);
--md-syntax-quote: var(--text-neutral-light-100);
--md-syntax-marker: var(--text-robin-500);
--md-syntax-code: var(--text-forest-700);
--md-syntax-link: var(--text-robin-500);
--md-syntax-variable: var(--text-sienna-500);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
}
.formatChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--text-sienna-400);
}
.formatLabel {
font-size: 12px;
font-weight: 500;
color: var(--l1-foreground);
}
.toolbarDivider {
width: 1px;
height: 16px;
flex-shrink: 0;
background: var(--l1-border);
}
.commands {
display: flex;
align-items: center;
gap: 2px;
}
.toolbarEnd {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.insertVariable {
white-space: nowrap;
}
.insertVariableIcon {
color: var(--text-amber-400);
}
// The ui library's dropdown assumes a global border-box reset this app doesn't
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
// `width: 100%` + padding, so in the portal they lay out content-box and
// overflow the popup by the padding — clipping the flush-right badge.
.variableMenu,
.variableMenu * {
box-sizing: border-box;
}
.variableMenu {
width: 320px;
}
// Shrinkable, so a clamped popup truncates the name instead of clipping the
// badge at the content's `overflow: hidden` edge.
.variableRow {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.variableName {
font-family: var(--font-family-sf-mono);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.variableBadge {
flex-shrink: 0;
margin-left: auto;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
border-radius: 4px;
font-family: var(--font-family-sf-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-amber-400);
}
.editorArea {
flex: 1;
min-height: 0;
overflow: hidden;
}
.codeMirror {
height: 100%;
font-family: var(--font-family-sf-mono);
font-size: 13px;
:global(.cm-editor) {
height: 100%;
background: transparent;
}
:global(.cm-editor.cm-focused) {
outline: none;
}
:global(.cm-gutters) {
background: transparent;
border-right: none;
color: var(--text-neutral-dark-200);
}
:global(.cm-scroller) {
line-height: 20px;
padding: 0 12px;
@include custom-scrollbar;
}
:global(.cm-content) {
padding: 8px 0;
}
}
.statusBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 6px 12px;
border-top: 1px solid var(--l1-border);
}
.statusPosition {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.statusSeparator {
color: var(--l1-border);
}
.statusCount {
color: inherit;
}
.statusCountOverLimit {
color: var(--text-cherry-400);
font-weight: 600;
}
.statusHint {
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.helpContent {
width: 280px;
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.helpTitle {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-neutral-dark-200);
}
.helpList {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
.helpRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
dt {
margin: 0;
code {
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-forest-400);
}
}
dd {
margin: 0;
font-size: 11px;
color: var(--text-neutral-dark-200);
}
}
// The help popover portals out of `.container`, so it can't inherit its tokens.
:global(body.lightMode) .helpRow dt code {
color: var(--text-forest-700);
}

View File

@@ -0,0 +1,245 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, {
type BasicSetupOptions,
EditorView,
type ViewUpdate,
} from '@uiw/react-codemirror';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
import EditorStatusBar from './EditorStatusBar';
import EditorToolbar from './EditorToolbar';
import { applyTransform, replaceDocument } from './editorDocument';
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
import { markdownHighlight } from './markdownHighlight';
import type {
CursorPosition,
EditorCommand,
EditorTransform,
EditorVariable,
} from './types';
import styles from './MarkdownEditor.module.scss';
/** What the status bar reports. */
type DocumentStatus = CursorPosition & { length: number };
// No language grammar is loaded, so bracket/indent/completion behaviour would only
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
const BASIC_SETUP: BasicSetupOptions = {
lineNumbers: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
foldGutter: false,
autocompletion: false,
bracketMatching: false,
closeBrackets: false,
indentOnInput: false,
syntaxHighlighting: false,
highlightSelectionMatches: false,
rectangularSelection: false,
crosshairCursor: false,
searchKeymap: false,
foldKeymap: false,
lintKeymap: false,
completionKeymap: false,
closeBracketsKeymap: false,
};
const EMPTY_VARIABLES: EditorVariable[] = [];
export interface MarkdownEditorProps {
/** Seeds the document; replaced only from outside. See the sync effect. */
value: string;
onChange: (value: string) => void;
/** Offered by the "Insert variable" menu; the button disables when empty. */
variables?: EditorVariable[];
/** What the character counter reports against. */
maxLength?: number;
placeholder?: string;
readOnly?: boolean;
/** Shown on the toolbar chip. */
formatLabel?: string;
/** Rendered before the "Insert variable" menu. */
toolbarExtra?: ReactNode;
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
statusHint?: ReactNode;
autoFocus?: boolean;
className?: string;
testId?: string;
}
/**
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
* the body, so the preview surface and its sanitisation stay the caller's concern.
*/
function MarkdownEditor({
value,
onChange,
variables = EMPTY_VARIABLES,
maxLength = MARKDOWN_MAX_LENGTH,
placeholder = 'Write Markdown…',
readOnly = false,
formatLabel = 'Markdown',
toolbarExtra,
statusHint,
autoFocus = false,
className,
testId = 'markdown-editor',
}: MarkdownEditorProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const viewRef = useRef<EditorView | null>(null);
// Set while a programmatic replacement is in flight, so the caller isn't told
// about a change it asked for. `dispatch` runs listeners synchronously, so the
// window is exactly one call.
const isSyncingRef = useRef(false);
const previousValueRef = useRef(value);
const hasSeededRef = useRef(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const [status, setStatus] = useState<DocumentStatus>(() => ({
line: 1,
column: 1,
length: value.length,
}));
const syncDocument = useCallback((view: EditorView, next: string): void => {
isSyncingRef.current = true;
replaceDocument(view, next);
isSyncingRef.current = false;
}, []);
const onCreateEditor = useCallback((view: EditorView): void => {
viewRef.current = view;
setIsEditorReady(true);
}, []);
/**
* Seeds the document, then applies external replacements — nothing else. Keeping
* keystrokes out of this round-trip is what stops a stale `value` from replacing
* the document and resetting the caret when typing outpaces React.
*
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
* `''` and reconciles against it once the view exists, wiping anything written
* before that. `isEditorReady` puts this effect after that pass, since a parent's
* effects flush after its children's.
*
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
* applied over the author.
*/
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const previous = previousValueRef.current;
previousValueRef.current = value;
const isSeeding = !hasSeededRef.current;
hasSeededRef.current = true;
if (!isSeeding && (value === previous || view.hasFocus)) {
return;
}
if (view.state.doc.toString() !== value) {
syncDocument(view, value);
}
}, [value, isEditorReady, syncDocument]);
const handleChange = useCallback(
(next: string): void => {
if (!isSyncingRef.current) {
onChange(next);
}
},
[onChange],
);
const runTransform = useCallback((transform: EditorTransform): void => {
const view = viewRef.current;
if (view) {
applyTransform(view, transform);
}
}, []);
const onRunCommand = useCallback(
(command: EditorCommand): void => runTransform(command.run),
[runTransform],
);
const onInsertVariable = useCallback(
(name: string): void =>
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
[runTransform],
);
const extensions = useMemo(
() => [markdownHighlight(), EditorView.lineWrapping],
[],
);
// From the document, not `value`: the caller may debounce or drop a change, and
// the counter has to match what the author sees.
const onUpdate = useCallback((update: ViewUpdate): void => {
if (!update.selectionSet && !update.docChanged) {
return;
}
const { head } = update.state.selection.main;
const line = update.state.doc.lineAt(head);
setStatus({
line: line.number,
column: head - line.from + 1,
length: update.state.doc.length,
});
}, []);
return (
<div className={cx(styles.container, className)} data-testid={testId}>
<EditorToolbar
formatLabel={formatLabel}
commands={MARKDOWN_COMMANDS}
onRunCommand={onRunCommand}
variables={variables}
onInsertVariable={onInsertVariable}
disabled={readOnly}
extra={toolbarExtra}
/>
<div className={styles.editorArea}>
<CodeMirror
className={styles.codeMirror}
// No `value`: passing it re-enables the wrapper's own reconciliation,
// and with it the caret reset.
onCreateEditor={onCreateEditor}
onChange={handleChange}
onUpdate={onUpdate}
theme={isDarkMode ? copilot : githubLight}
basicSetup={BASIC_SETUP}
placeholder={placeholder}
editable={!readOnly}
readOnly={readOnly}
indentWithTab={false}
autoFocus={autoFocus}
extensions={extensions}
height="100%"
/>
</div>
<EditorStatusBar
cursor={status}
length={status.length}
maxLength={maxLength}
hint={statusHint}
/>
</div>
);
}
export default MarkdownEditor;

View File

@@ -0,0 +1,44 @@
import { CircleHelp } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import { MARKDOWN_HELP_ITEMS } from './constants';
import styles from './MarkdownEditor.module.scss';
function MarkdownHelp(): JSX.Element {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label="Markdown syntax help"
data-testid="markdown-help-trigger"
>
<CircleHelp size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className={styles.helpContent}>
<Typography.Text className={styles.helpTitle}>
Markdown syntax
</Typography.Text>
<dl className={styles.helpList}>
{MARKDOWN_HELP_ITEMS.map((item) => (
<div key={item.syntax} className={styles.helpRow}>
<dt>
<code>{item.syntax}</code>
</dt>
<dd>{item.label}</dd>
</div>
))}
</dl>
</PopoverContent>
</Popover>
);
}
export default MarkdownHelp;

View File

@@ -0,0 +1,270 @@
import { useCallback, useRef, useState } from 'react';
import { EditorView } from '@uiw/react-codemirror';
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import MarkdownEditor from '../MarkdownEditor';
import type { EditorVariable } from '../types';
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => true,
}));
const VARIABLES: EditorVariable[] = [
{ name: 'environment', badge: 'QUERY' },
{ name: 'service', badge: 'CUSTOM' },
];
/** A caller whose state trails the editor by one keystroke. */
function LaggingHarness(): JSX.Element {
const [value, setValue] = useState('');
const previousRef = useRef('');
const onChange = useCallback((next: string): void => {
setValue(previousRef.current);
previousRef.current = next;
}, []);
return <MarkdownEditor value={value} onChange={onChange} />;
}
/** Pushes a replacement in from outside the editor. */
function ExternalHarness(): JSX.Element {
const [value, setValue] = useState('before');
return (
<>
<button type="button" onClick={(): void => setValue('after')}>
push
</button>
<MarkdownEditor value={value} onChange={setValue} />
</>
);
}
function Harness({
initialValue = '',
maxLength,
variables = VARIABLES,
}: {
initialValue?: string;
maxLength?: number;
variables?: EditorVariable[];
}): JSX.Element {
const [value, setValue] = useState(initialValue);
return (
<MarkdownEditor
value={value}
onChange={setValue}
variables={variables}
maxLength={maxLength}
statusHint="Preview updates as you type"
/>
);
}
const getView = (): EditorView => {
const dom = document.querySelector('.cm-editor');
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
if (!view) {
throw new Error('editor view not mounted');
}
return view;
};
const select = (from: number, to: number): void => {
act(() => {
getView().dispatch({ selection: { anchor: from, head: to } });
});
};
const documentText = (): string => getView().state.doc.toString();
describe('MarkdownEditor', () => {
it('reports the caret position and character count', async () => {
render(<Harness initialValue={'one\ntwo'} />);
select(5, 5);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
'Ln 2, Col 2',
);
});
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'7 chars',
);
});
it('flags a body over the character cap', async () => {
render(<Harness initialValue="123456" maxLength={5} />);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'6 / 5 chars',
);
});
});
it('applies a toolbar command to the selection', async () => {
render(<Harness initialValue="a word b" />);
select(2, 6);
await userEvent.click(screen.getByTestId('markdown-command-bold'));
await waitFor(() => {
expect(documentText()).toBe('a **word** b');
});
});
it('inserts a variable token at the caret', async () => {
render(<Harness initialValue="env: " />);
select(5, 5);
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
// The row shows the name and kind badge.
const row = await screen.findByTestId('markdown-variable-environment');
expect(row).toHaveTextContent('$environment');
expect(row).toHaveTextContent('QUERY');
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
// CodeMirror mocks stub out.
fireEvent.click(row);
await waitFor(() => {
expect(documentText()).toBe('env: $environment');
});
});
it('colours Markdown syntax and variable tokens in the source', async () => {
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
await waitFor(() => {
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
});
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
'{{team}}',
);
});
describe('uncontrolled document', () => {
const type = (at: number, text: string): void => {
act(() => {
getView().dispatch({
changes: { from: at, insert: text },
selection: { anchor: at + text.length },
});
});
};
const focusEditor = (): void => {
act(() => {
getView().focus();
});
};
it('keeps the document and caret while the caller lags behind the typing', () => {
render(<LaggingHarness />);
focusEditor();
type(0, 'a');
type(1, 'b');
type(2, 'c');
expect(documentText()).toBe('abc');
expect(getView().state.selection.main.head).toBe(3);
});
it('reports every keystroke to the caller', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="ab" onChange={onChange} />);
type(2, 'c');
expect(onChange).toHaveBeenLastCalledWith('abc');
});
it('does not report the seed back as a change', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="seeded" onChange={onChange} />);
expect(documentText()).toBe('seeded');
expect(onChange).not.toHaveBeenCalled();
});
it('applies an external replacement while the editor is unfocused', async () => {
render(<ExternalHarness />);
await userEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('after');
});
it('ignores a replacement that arrives while the author is still typing', () => {
render(<ExternalHarness />);
focusEditor();
// fireEvent: a real click would blur the editor first. This covers an update
// arriving on its own, while the author is still in the document.
fireEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('before');
});
it('counts characters from the document, not from the lagging value', async () => {
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
type(2, 'cde');
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'5 chars',
);
});
});
});
it('offers both list kinds in the toolbar', () => {
render(<Harness />);
expect(
screen.getByTestId('markdown-command-bulleted-list'),
).toBeInTheDocument();
expect(
screen.getByTestId('markdown-command-numbered-list'),
).toBeInTheDocument();
});
it('disables authoring affordances when read-only', () => {
render(
<MarkdownEditor
value="body"
onChange={jest.fn()}
variables={VARIABLES}
readOnly
/>,
);
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
});
it('hides the insert-variable control when none are available', () => {
render(<Harness variables={[]} />);
expect(
screen.queryByTestId('markdown-insert-variable'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,258 @@
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
import type { EditorSnapshot, EditorTransform } from '../types';
const commandById = (id: string): EditorTransform => {
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
if (!command) {
throw new Error(`unknown command: ${id}`);
}
return command.run;
};
const heading = commandById('heading');
const bold = commandById('bold');
const italic = commandById('italic');
const bulletedList = commandById('bulleted-list');
const numberedList = commandById('numbered-list');
const link = commandById('link');
const code = commandById('code');
const table = commandById('table');
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
const snapshot = (marked: string): EditorSnapshot => {
if (marked.includes('|')) {
const caret = marked.indexOf('|');
return {
text: marked.replace('|', ''),
selectionStart: caret,
selectionEnd: caret,
};
}
const start = marked.indexOf('[');
const end = marked.indexOf(']') - 1;
return {
text: marked.replace('[', '').replace(']', ''),
selectionStart: start,
selectionEnd: end,
};
};
const selectionOf = (result: EditorSnapshot): string =>
result.text.slice(result.selectionStart, result.selectionEnd);
describe('heading', () => {
it('prefixes the caret line and keeps the caret on the same character', () => {
const result = heading(snapshot('Chec|kout'));
expect(result.text).toBe('## Checkout');
expect(result.selectionStart).toBe(7);
});
it('strips the prefix when every selected line already has one', () => {
const result = heading({
text: '## one\n### two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
it('adds the prefix when only some selected lines have one', () => {
const result = heading({
text: '## one\ntwo',
selectionStart: 0,
selectionEnd: 10,
});
expect(result.text).toBe('## ## one\n## two');
});
it('does not pull in the line after a selection ending on a line break', () => {
const result = heading({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 4,
});
expect(result.text).toBe('## one\ntwo');
});
});
describe('bulleted list', () => {
it('bullets every line of a multi-line selection', () => {
const result = bulletedList({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('- one\n- two');
expect(selectionOf(result)).toBe('- one\n- two');
});
it('unbullets a list written with a different marker', () => {
const result = bulletedList({
text: '* one\n+ two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('numbered list', () => {
it('numbers each line of the selection in order', () => {
const result = numberedList({
text: 'one\ntwo\nthree',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('1. one\n2. two\n3. three');
});
it('unnumbers a list whose numbering is not sequential', () => {
const result = numberedList({
text: '1. one\n5. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('switching between list kinds', () => {
it('converts bullets to numbers rather than marking them twice', () => {
const result = numberedList({
text: '- one\n- two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('1. one\n2. two');
});
it('converts numbers to bullets', () => {
const result = bulletedList({
text: '1. one\n2. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('- one\n- two');
});
it('keeps indentation so nested items stay nested', () => {
const result = numberedList({
text: 'one\n - nested',
selectionStart: 0,
selectionEnd: 16,
});
expect(result.text).toBe('1. one\n 2. nested');
});
});
describe('bold and italic', () => {
it('wraps the selection and keeps the original text selected', () => {
const result = bold(snapshot('a [word] b'));
expect(result.text).toBe('a **word** b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit inside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 2,
selectionEnd: 10,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit just outside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 4,
selectionEnd: 8,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('leaves the caret between the markers when nothing is selected', () => {
const result = italic(snapshot('a |b'));
expect(result.text).toBe('a __b');
expect(result.selectionStart).toBe(3);
expect(result.selectionEnd).toBe(3);
});
it('does not mistake a leading document boundary for a marker', () => {
const result = bold(snapshot('[word] tail'));
expect(result.text).toBe('**word** tail');
});
});
describe('link', () => {
it('selects the url when the label came from the selection', () => {
const result = link(snapshot('see [docs] now'));
expect(result.text).toBe('see [docs](https://) now');
expect(selectionOf(result)).toBe('https://');
});
it('selects the label placeholder when nothing was selected', () => {
const result = link(snapshot('see |'));
expect(result.text).toBe('see [text](https://)');
expect(selectionOf(result)).toBe('text');
});
});
describe('code', () => {
it('uses backticks for a single-line selection', () => {
const result = code(snapshot('run [npm] here'));
expect(result.text).toBe('run `npm` here');
});
it('fences a multi-line selection and selects its content', () => {
const result = code({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('```\none\ntwo\n```');
expect(selectionOf(result)).toBe('one\ntwo');
});
});
describe('table', () => {
it('starts the skeleton on its own line and selects the first header cell', () => {
const result = table(snapshot('intro|'));
expect(result.text).toBe(
'intro\n| Column | Column |\n| --- | --- |\n| | |',
);
expect(selectionOf(result)).toBe('Column');
});
});
describe('insertText', () => {
it('replaces the selection and leaves the caret after the insertion', () => {
const result = insertText(snapshot('env is [old]'), '{{env}}');
expect(result.text).toBe('env is {{env}}');
expect(result.selectionStart).toBe(14);
expect(result.selectionEnd).toBe(14);
});
});

View File

@@ -0,0 +1,24 @@
// The body is persisted inline in the dashboard JSON, so its length is capped.
export const MARKDOWN_MAX_LENGTH = 16000;
/** The canonical syntax; the renderer resolves the other three too. */
export const formatVariableToken = (name: string): string => `$${name}`;
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
// First: consecutive lines joining into one paragraph is the CommonMark rule
// authors trip over before any of the formatting syntax.
{ syntax: 'blank line', label: 'New paragraph' },
{ syntax: '2 spaces + ⏎', label: 'Line break' },
{ syntax: '# Heading', label: 'Heading (16 #)' },
{ syntax: '**bold**', label: 'Bold' },
{ syntax: '_italic_', label: 'Italic' },
{ syntax: '- item', label: 'Bulleted list' },
{ syntax: '1. item', label: 'Numbered list' },
{ syntax: '- [ ] task', label: 'Task list' },
{ syntax: '[label](url)', label: 'Link' },
{ syntax: '![alt](url)', label: 'Image' },
{ syntax: '`code`', label: 'Inline code' },
{ syntax: '```lang', label: 'Code block' },
{ syntax: '> quote', label: 'Blockquote' },
{ syntax: '| a | b |', label: 'Table' },
];

View File

@@ -0,0 +1,69 @@
import { EditorView } from '@uiw/react-codemirror';
import type { EditorSnapshot, EditorTransform } from './types';
// Narrows a whole-document replacement to the range that changed, so a toolbar
// action doesn't invalidate the document's decorations or scroll position.
function toChangeSpec(
previous: string,
next: string,
): { from: number; to: number; insert: string } | null {
if (previous === next) {
return null;
}
const shorter = Math.min(previous.length, next.length);
let start = 0;
while (start < shorter && previous[start] === next[start]) {
start += 1;
}
let previousEnd = previous.length;
let nextEnd = next.length;
while (
previousEnd > start &&
nextEnd > start &&
previous[previousEnd - 1] === next[nextEnd - 1]
) {
previousEnd -= 1;
nextEnd -= 1;
}
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
}
export function readSnapshot(view: EditorView): EditorSnapshot {
const range = view.state.selection.main;
return {
text: view.state.doc.toString(),
selectionStart: range.from,
selectionEnd: range.to,
};
}
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
export function applyTransform(
view: EditorView,
transform: EditorTransform,
): boolean {
if (view.state.readOnly) {
return false;
}
const next = transform(readSnapshot(view));
const changes = toChangeSpec(view.state.doc.toString(), next.text);
view.dispatch({
...(changes ? { changes } : {}),
selection: { anchor: next.selectionStart, head: next.selectionEnd },
scrollIntoView: true,
});
view.focus();
return true;
}
/** Replaces the whole document, for seeding and external replacements. */
export function replaceDocument(view: EditorView, next: string): void {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: next },
});
}

View File

@@ -0,0 +1,269 @@
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
const BOLD_MARKER = '**';
const ITALIC_MARKER = '_';
const INLINE_CODE_MARKER = '`';
const CODE_FENCE = '```';
const HEADING_PREFIX = '## ';
const BULLET_MARKER = '- ';
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
// Either kind of marker, matched after the indent has been split off.
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
const INDENT_PATTERN = /^[ \t]*/;
const LINK_LABEL_PLACEHOLDER = 'text';
const LINK_URL_PLACEHOLDER = 'https://';
const TABLE_CELL_PLACEHOLDER = 'Column';
const TABLE_SNIPPET = [
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
'| --- | --- |',
'| | |',
].join('\n');
interface LineRange {
start: number;
end: number;
}
// A selection ending exactly on a line break stops there rather than pulling in
// the next line, so "select the line, hit list" doesn't bullet the line below too.
function expandToLines(text: string, from: number, to: number): LineRange {
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
const breakAfter = text.indexOf('\n', end);
return {
start: breakBefore + 1,
end: breakAfter === -1 ? text.length : breakAfter,
};
}
/** Pads `block` so it starts and ends on its own line. */
function replaceWithBlock(
snapshot: EditorSnapshot,
block: string,
): { text: string; blockStart: number } {
const { text, selectionStart, selectionEnd } = snapshot;
const before = text.slice(0, selectionStart);
const after = text.slice(selectionEnd);
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
return {
text: before + lead + block + trail + after,
blockStart: before.length + lead.length,
};
}
/** Rewrites every line the selection touches. */
function replaceLines(
snapshot: EditorSnapshot,
mapLines: (lines: string[]) => string[],
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
const lines = text.slice(start, end).split('\n');
const nextLines = mapLines(lines);
const block = nextLines.join('\n');
const nextText = text.slice(0, start) + block + text.slice(end);
if (selectionStart !== selectionEnd) {
return {
text: nextText,
selectionStart: start,
selectionEnd: start + block.length,
};
}
// Caret-only: the range covers one line, so shift by that line's delta.
const shifted = selectionStart + nextLines[0].length - lines[0].length;
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
return { text: nextText, selectionStart: caret, selectionEnd: caret };
}
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line) =>
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
);
});
}
/**
* Toggles this kind of list marker. A line carrying the *other* kind is converted
* rather than marked twice, and indentation is preserved so nesting survives.
* `markerAt` takes the line's position, which is what lets an ordered list number.
*/
function toggleList(
pattern: RegExp,
markerAt: (index: number) => string,
): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line, index) => {
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
return isApplied
? `${indent}${body}`
: `${indent}${markerAt(index)}${body}`;
});
});
}
/**
* Unwraps when the markers are already there, whether they sit inside the selection
* (`**bold**` selected whole) or just outside it (only `bold` selected).
*/
function toggleWrap(marker: string): EditorTransform {
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const width = marker.length;
if (
selected.length >= width * 2 &&
selected.startsWith(marker) &&
selected.endsWith(marker)
) {
const inner = selected.slice(width, -width);
return {
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
selectionStart,
selectionEnd: selectionStart + inner.length,
};
}
if (
selectionStart >= width &&
text.slice(selectionStart - width, selectionStart) === marker &&
text.slice(selectionEnd, selectionEnd + width) === marker
) {
return {
text:
text.slice(0, selectionStart - width) +
selected +
text.slice(selectionEnd + width),
selectionStart: selectionStart - width,
selectionEnd: selectionStart - width + selected.length,
};
}
return {
text:
text.slice(0, selectionStart) +
marker +
selected +
marker +
text.slice(selectionEnd),
selectionStart: selectionStart + width,
selectionEnd: selectionStart + width + selected.length,
};
};
}
/** Lands the selection on whichever half is still a placeholder. */
const insertLink: EditorTransform = ({
text,
selectionStart,
selectionEnd,
}): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const label = selected || LINK_LABEL_PLACEHOLDER;
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
const nextText =
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
// `[` + label + `](` is label.length + 3 characters.
const target = selected
? {
from: selectionStart + label.length + 3,
length: LINK_URL_PLACEHOLDER.length,
}
: { from: selectionStart + 1, length: label.length };
return {
text: nextText,
selectionStart: target.from,
selectionEnd: target.from + target.length,
};
};
/** Backticks for a single-line selection, a fence for a multi-line one. */
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
const { text, selectionStart, selectionEnd } = snapshot;
const selected = text.slice(selectionStart, selectionEnd);
if (!selected.includes('\n')) {
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
}
const { text: nextText, blockStart } = replaceWithBlock(
snapshot,
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
);
const contentStart = blockStart + CODE_FENCE.length + 1;
return {
text: nextText,
selectionStart: contentStart,
selectionEnd: contentStart + selected.length,
};
};
/** Selects the first header cell, for immediate typing. */
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
return {
text,
selectionStart: firstCell,
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
};
};
/** Replaces the selection and leaves the caret after the insertion. */
export function insertText(
snapshot: EditorSnapshot,
value: string,
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const caret = selectionStart + value.length;
return {
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
selectionStart: caret,
selectionEnd: caret,
};
}
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
export const MARKDOWN_COMMANDS: EditorCommand[] = [
{
id: 'heading',
label: 'Heading',
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
},
{
id: 'bold',
label: 'Bold',
run: toggleWrap(BOLD_MARKER),
},
{
id: 'italic',
label: 'Italic',
run: toggleWrap(ITALIC_MARKER),
},
{
id: 'bulleted-list',
label: 'Bulleted list',
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
},
{
id: 'numbered-list',
label: 'Numbered list',
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
},
{ id: 'link', label: 'Link', run: insertLink },
{ id: 'code', label: 'Code', run: insertCode },
{ id: 'table', label: 'Table', run: insertTable },
];

View File

@@ -0,0 +1,149 @@
import type { Extension, Line, Range } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const QUOTE_PATTERN = /^ {0,3}> ?/;
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
/**
* Convention: capture group 1, when present, is a left guard the token excludes —
* the token runs from the end of that group to the end of the match. Lookbehind is
* avoided for Safari compatibility, so guards are captured rather than asserted.
*/
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
{
// The four variable syntaxes a dashboard body may carry.
pattern:
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
className: 'cm-md-variable',
},
];
const MARKS = {
heading: Decoration.mark({ class: 'cm-md-heading' }),
quote: Decoration.mark({ class: 'cm-md-quote' }),
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
code: Decoration.mark({ class: 'cm-md-code' }),
} as const;
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
pattern,
mark: Decoration.mark({ class: className }),
}));
function pushInlineMarks(
lineText: string,
lineFrom: number,
ranges: Range<Decoration>[],
): void {
INLINE_MARKS.forEach(({ pattern, mark }) => {
pattern.lastIndex = 0;
let match = pattern.exec(lineText);
while (match !== null) {
const guardLength = match[1]?.length ?? 0;
const from = lineFrom + match.index + guardLength;
const to = lineFrom + match.index + match[0].length;
if (to > from) {
ranges.push(mark.range(from, to));
}
match = pattern.exec(lineText);
}
});
}
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
if (HEADING_PATTERN.test(line.text)) {
ranges.push(MARKS.heading.range(line.from, line.to));
return;
}
if (QUOTE_PATTERN.test(line.text)) {
ranges.push(MARKS.quote.range(line.from, line.to));
return;
}
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
if (listMarker) {
ranges.push(
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
);
}
}
// Scans the whole document rather than the viewport: fenced blocks opening above
// the visible range would otherwise be mis-detected. Bounded by the length cap.
function buildDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const ranges: Range<Decoration>[] = [];
let isInsideFence = false;
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
const line = doc.line(lineNumber);
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
if (isFenceDelimiter || isInsideFence) {
if (line.to > line.from) {
ranges.push(MARKS.code.range(line.from, line.to));
}
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
} else {
pushBlockMark(line, ranges);
pushInlineMarks(line.text, line.from, ranges);
}
}
return Decoration.set(ranges, true);
}
// Colours come from custom properties so the SCSS module owns light/dark.
const syntaxTheme = EditorView.theme({
'.cm-md-heading': {
color: 'var(--md-syntax-heading)',
fontWeight: '600',
},
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
'.cm-md-code': { color: 'var(--md-syntax-code)' },
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
'.cm-md-emphasis': {
color: 'var(--md-syntax-emphasis)',
fontStyle: 'italic',
},
'.cm-md-link': { color: 'var(--md-syntax-link)' },
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
});
const highlightPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: (plugin): DecorationSet => plugin.decorations },
);
/**
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
* colouring pass over a body the renderer parses for real.
*/
export function markdownHighlight(): Extension {
return [highlightPlugin, syntaxTheme];
}

View File

@@ -0,0 +1,27 @@
/** The value every editor command reads and returns. */
export interface EditorSnapshot {
text: string;
selectionStart: number;
selectionEnd: number;
}
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
export interface EditorVariable {
name: string;
/** Short tag for the variable's kind, e.g. "QUERY". */
badge?: string;
}
export interface EditorCommand {
id: string;
/** Accessible name and tooltip for the toolbar button. */
label: string;
run: EditorTransform;
}
/** 1-based, as the status bar reports it. */
export interface CursorPosition {
line: number;
column: number;
}

View File

@@ -1,5 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -76,7 +75,7 @@ export function buildEntityMetricsChartConfig({
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
});
builder.addAxis({
@@ -85,7 +84,6 @@ export function buildEntityMetricsChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {

View File

@@ -1,5 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -72,7 +71,7 @@ export function buildMeterChartConfig({
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.BAR,
isTimeAxis: true,
});
builder.addAxis({
@@ -81,7 +80,6 @@ export function buildMeterChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {

View File

@@ -1,5 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
@@ -7,11 +6,6 @@ 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
@@ -67,12 +61,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { panelType } = this.props;
const { isTimeAxis } = this.props;
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}

View File

@@ -1,5 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import type uPlot from 'uplot';
@@ -137,11 +136,11 @@ describe('UPlotAxisBuilder', () => {
});
});
it('uses time-based X-axis values formatter for time-series like panels', () => {
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
}),
);
@@ -150,11 +149,11 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBe(uPlotXAxisValuesFormat);
});
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
it('does not attach X-axis datetime formatter for a non-time axis', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
isTimeAxis: false,
}),
);
@@ -290,22 +289,9 @@ describe('UPlotAxisBuilder', () => {
expect(config.space).toBe(50);
});
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('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('should return the existing size when cycleNum > 1', () => {

View File

@@ -1,5 +1,4 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
@@ -53,31 +52,51 @@ export interface ConfigBuilderProps {
* Props for configuring an axis
*/
export interface AxisProps {
/** Scale this axis is drawn against — `'x'` / `'y'`, matching an `addScale` key. Also
* selects the default tick formatter and sizing (x: time, y: value + unit). */
scaleKey: string;
/** Axis title drawn alongside the ticks; omitted when there's nothing to name. */
label?: string;
/** Render the axis at all; false keeps the scale but draws no ticks or labels. */
show?: boolean;
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
/** Which edge of the plot the axis sits on: 0 | 1 | 2 | 3 — top, right, bottom, left. */
side?: 0 | 1 | 2 | 3;
/** Tick/label color. Defaults to black or white from `isDarkMode`. */
stroke?: string;
/** Partial override of the grid lines; unset keys fall back to the theme defaults. */
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
/** Partial override of the tick marks; provided as-is to uPlot when set. */
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
/** Minimum pixels between ticks, capping how many uPlot draws. For log scale axes. */
space?: number;
/** Picks the dark or light default for stroke and grid color. */
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
yAxisUnit?: string;
panelType?: PANEL_TYPES;
/**
* 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;
/** Decimal places for y axis tick values; unset lets the unit formatter decide. */
decimalPrecision?: PrecisionOption;
}

View File

@@ -124,7 +124,9 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
panelType,
// Graph and bar plot time on X; every other panel type here does not.
isTimeAxis:
panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR,
});
builder.addAxis({
@@ -134,7 +136,6 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -13,7 +13,6 @@ 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/QueryBuilder/rawQueryEditors/ClickHouse';
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -64,8 +63,12 @@ function PanelEditorQueryBuilder({
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -112,9 +115,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
showTraceOperator={!isListViewPanel}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
isListViewPanel={isListViewPanel}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
@@ -148,7 +151,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode]);
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
return (
<div

View File

@@ -60,6 +60,7 @@ function renderBuilder(
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
showTraceOperator: boolean;
filterConfigs: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
@@ -115,6 +116,9 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
// The trace operator combines aggregated trace queries, so it rides along with
// the aggregation controls.
expect(props.showTraceOperator).toBe(true);
expect(props.filterConfigs).toStrictEqual({});
});
@@ -124,6 +128,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.showTraceOperator).toBe(false);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },

View File

@@ -1,26 +1,14 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'components/QueryTypeTag/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;
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 panels and before a
* query exists, where the mode is irrelevant.
*/
function PlotTag({
queryType,
panelType,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
function PlotTag({ queryType, className }: PlotTagProps): JSX.Element | null {
if (queryType === undefined) {
return null;
}

View File

@@ -7,7 +7,6 @@ import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsL
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -72,7 +71,6 @@ 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,11 +82,7 @@ function PreviewPane({
<div className={styles.preview}>
{!hideHeader && (
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<PlotTag queryType={queryType} className={styles.queryType} />
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>

View File

@@ -1,30 +1,17 @@
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} panelType={PANEL_TYPES.TIME_SERIES} />,
);
render(<PlotTag queryType={EQueryType.PROM} />);
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} panelType={PANEL_TYPES.TIME_SERIES} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
render(<PlotTag queryType={undefined} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -4,7 +4,10 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
@@ -91,8 +94,9 @@ export function usePanelEditSession({
const query = usePanelQuery({
panel: draft,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
time,
enabled: !!panelDefinition,
enabled: isPanelKindSupported(panelKind),
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({

View File

@@ -6,7 +6,7 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
@@ -146,7 +146,7 @@ export function usePanelTypeSwitch({
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newPanelType === PANEL_TYPES.LIST
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]

View File

@@ -1,7 +1,14 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
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,
@@ -15,6 +22,7 @@ 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],
@@ -37,9 +45,117 @@ 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,
},
// 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,
},
'signoz/HistogramPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/NumberPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/PieChartPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// 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,
},
// 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,
},
};
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).queryCapabilities).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 { queryCapabilities } = getPanelDefinition(unknownKind);
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);
});
});
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(

View File

@@ -20,8 +20,12 @@ interface NoDataProps {
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
panel?: DashboardtypesPanelDTO;
/**
* The panel this empty state stands in for. Every renderer has it, and it decides
* whether the global "Extend time range" action applies (a panel locked to a fixed
* time preference can't be widened by it) as well as what the action events report.
*/
panel: DashboardtypesPanelDTO;
'data-testid'?: string;
}
@@ -43,19 +47,17 @@ function NoData({
const globalExtend = useExtendTimeWindow();
// The View modal's local extender wins; the global one only applies to a panel that
// follows the ambient window (a fixed preference can't be widened by it).
const hasFixedTimePreference = panel
? panelHasFixedTimePreference(panel)
: false;
const activeExtend =
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
viewExtend ?? (panelHasFixedTimePreference(panel) ? undefined : globalExtend);
if (isFetching) {
return <PanelLoader />;
}
const panelType = panel
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
: undefined;
// `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 = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel
@@ -65,6 +67,7 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'extendTime',
panelType,
panelKind,
});
activeExtend.extend();
},
@@ -79,6 +82,7 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'retry',
panelType,
panelKind,
});
onRetry();
},

View File

@@ -33,7 +33,12 @@ function panelWith(
timePreference?: DashboardtypesTimePreferenceDTO,
): DashboardtypesPanelDTO {
return {
spec: { plugin: { spec: { visualization: { timePreference } } } },
spec: {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { visualization: { timePreference } },
},
},
} as unknown as DashboardtypesPanelDTO;
}
@@ -44,7 +49,7 @@ describe('NoData', () => {
});
it('renders the empty-state title and hint', () => {
render(<NoData />);
render(<NoData panel={panelWith()} />);
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
@@ -55,7 +60,7 @@ describe('NoData', () => {
it('offers to extend the window as the primary action', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData />);
render(<NoData panel={panelWith()} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Extend time range');
@@ -68,7 +73,7 @@ describe('NoData', () => {
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData onRetry={onRetry} />);
render(<NoData onRetry={onRetry} panel={panelWith()} />);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
@@ -82,7 +87,7 @@ describe('NoData', () => {
it('falls back to Retry as the sole action when the window cannot be widened', () => {
const onRetry = jest.fn();
render(<NoData onRetry={onRetry} />);
render(<NoData onRetry={onRetry} panel={panelWith()} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
@@ -101,7 +106,7 @@ describe('NoData', () => {
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(<NoData />);
render(<NoData panel={panelWith()} />);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
@@ -109,7 +114,7 @@ describe('NoData', () => {
});
it('renders no action when nothing can be widened and no retry handler', () => {
render(<NoData />);
render(<NoData panel={panelWith()} />);
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
expect(
@@ -119,7 +124,7 @@ describe('NoData', () => {
it('shows the panel loader (not the empty state) while refetching', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData isFetching />);
render(<NoData isFetching panel={panelWith()} />);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
@@ -128,7 +133,7 @@ describe('NoData', () => {
it('honours the data-testid override for the number panel', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData data-testid="number-panel-no-data" />);
render(<NoData data-testid="number-panel-no-data" panel={panelWith()} />);
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
@@ -20,6 +23,15 @@ 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).
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,33 +1,22 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
buildBaseConfig,
type TimeAxisChromeArgs,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
export interface BuildBarChartConfigArgs extends TimeAxisChromeArgs {
spec: DashboardtypesBarChartPanelSpecDTO;
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */
@@ -47,7 +36,7 @@ export function buildBarChartConfig({
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isTimeAxis: true,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
@@ -20,6 +23,15 @@ 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.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,8 +1,8 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
buildBaseConfig,
type PanelChromeArgs,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import getLabelName from 'lib/getLabelName';
@@ -16,16 +16,12 @@ const BAR_WIDTH_FACTOR = 1;
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
export interface BuildHistogramConfigArgs extends PanelChromeArgs {
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
@@ -44,7 +40,7 @@ export function buildHistogramConfig({
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isTimeAxis: false,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
@@ -30,6 +33,15 @@ 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.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
@@ -20,6 +23,13 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
@@ -16,6 +19,13 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
@@ -16,6 +19,14 @@ 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.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -0,0 +1,25 @@
// Tripled on purpose: the markdown reset (`.content.content *`) weighs (0,2,0),
// and a doubled class only ties it — leaving the winner to stylesheet order,
// which reverted `position: relative` and let the copy button anchor to the
// panel instead of the block. (0,3,0) wins regardless of order.
.codeBlock.codeBlock.codeBlock {
position: relative;
}
// The wrapper is a reset-exempt island (see MarkdownContent.module.scss), so
// plain classes style it; the button inside keeps its design-system look.
.copyButton {
position: absolute;
top: 4px;
right: 4px;
border-radius: 3px;
background: var(--l3-background);
opacity: 0;
transition: opacity 0.15s ease;
}
// GitHub-style reveal: hover anywhere on the block, or keyboard focus.
.codeBlock.codeBlock.codeBlock:hover .copyButton,
.codeBlock.codeBlock.codeBlock:focus-within .copyButton {
opacity: 1;
}

View File

@@ -0,0 +1,69 @@
import type { CodeProps } from 'react-markdown/lib/ast-to-react';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import SyntaxHighlighter, { resolveLanguage } from './syntaxLanguages';
import { usePrismLanguage } from './usePrismLanguage';
import styles from './CodeBlock.module.scss';
const LANGUAGE_PATTERN = /language-(\w+)/;
/**
* Fenced blocks are tokenised by Prism but coloured by the SCSS module —
* `useInlineStyles` off swaps the library's own theme for `token …` class names,
* which keeps the palette on design tokens and themed with the rest of the body.
*/
function CodeBlock({ inline, className, children }: CodeProps): JSX.Element {
const fenced = LANGUAGE_PATTERN.exec(className ?? '')?.[1]?.toLowerCase();
const language = fenced ? resolveLanguage(fenced) : null;
const isReady = usePrismLanguage(language);
if (inline) {
return <code className={className}>{children}</code>;
}
// react-markdown hands the block's text through as string children; anything
// else in there is not source and has no place in the highlighter's input —
// and it is exactly what the copy button puts on the clipboard.
const source = (Array.isArray(children) ? children : [children])
.filter((child): child is string => typeof child === 'string')
.join('')
.replace(/\n$/, '');
// Verbatim while the language chunk is still loading, and permanently for one
// Prism doesn't know. The `pre` is supplied here either way, since
// `MarkdownContent` unwraps react-markdown's own.
const block =
!language || !isReady ? (
<pre>
<code className={className}>{children}</code>
</pre>
) : (
<SyntaxHighlighter
language={language}
useInlineStyles={false}
PreTag="pre"
CodeTag="code"
>
{source}
</SyntaxHighlighter>
);
return (
<div className={styles.codeBlock}>
{block}
{/* data-md-ui: exempts the design-system button from the body's style reset. */}
<span data-md-ui className={styles.copyButton}>
<CopyButton
value={source}
size={13}
ariaLabel="Copy code"
testId="text-panel-copy-code"
/>
</span>
</div>
);
}
export default CodeBlock;

View File

@@ -0,0 +1,324 @@
@use '../../../../../../styles/scrollbar' as *;
// Style isolation: the subtree is rolled back to user-agent styling, so no global
// rule reaches the rendered body and the rules below are the only author styles that
// apply. `all` skips custom properties, so tokens still resolve, and inherited
// properties the caller owns (`text-align`, set by the panel's presentation options)
// still flow in. The class is doubled throughout so a global `.wrapper p` can't tie
// on specificity and win on source order.
// `[data-md-ui]` marks injected UI islands (the code-block copy button) that keep
// their design-system styling: `:where(:not(…))` skips them and their subtrees at
// zero added specificity, so the island escape doesn't out-rank the body rules.
.content.content,
.content.content *:where(:not([data-md-ui], [data-md-ui] *)) {
all: revert;
box-sizing: border-box;
}
.content.content {
--md-foreground: var(--text-vanilla-100);
--md-muted: var(--text-neutral-dark-100);
--md-link: var(--text-robin-400);
--md-border: var(--l1-border);
--md-surface: color-mix(in srgb, var(--l1-foreground) 6%, transparent);
--md-code-comment: var(--text-neutral-dark-200);
--md-code-punctuation: var(--text-neutral-dark-100);
--md-code-keyword: var(--text-sakura-400);
--md-code-string: var(--text-forest-400);
--md-code-number: var(--text-amber-400);
--md-code-function: var(--text-robin-300);
--md-code-property: var(--text-aqua-400);
// Fits a two-digit ordered marker (`10.`). Shared by the task-list offset.
--md-list-indent: 24px;
display: block;
font-family: var(--font-family-inter);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-normal);
line-height: var(--paragraph-base-400-line-height);
color: var(--md-foreground);
overflow-wrap: break-word;
}
:global(body.lightMode) .content.content {
--md-foreground: var(--text-ink-400);
--md-muted: var(--text-neutral-light-100);
--md-link: var(--text-robin-500);
--md-code-comment: var(--text-neutral-light-100);
--md-code-punctuation: var(--text-neutral-light-100);
--md-code-keyword: var(--text-sakura-600);
--md-code-string: var(--text-forest-700);
--md-code-number: var(--text-amber-800);
--md-code-function: var(--text-robin-600);
--md-code-property: var(--text-aqua-700);
}
.content.content > :first-child {
margin-top: 0;
}
.content.content > :last-child {
margin-bottom: 0;
}
.content.content p {
margin: 0 0 8px;
}
.content.content h1,
.content.content h2,
.content.content h3,
.content.content h4,
.content.content h5,
.content.content h6 {
margin: 16px 0 8px;
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
color: var(--md-foreground);
}
.content.content h1 {
font-size: var(--font-size-lg);
}
.content.content h2 {
font-size: var(--label-medium-600-font-size);
}
.content.content h3 {
font-size: var(--font-size-sm);
}
.content.content h4,
.content.content h5,
.content.content h6 {
font-size: var(--paragraph-base-600-font-size);
}
.content.content h5,
.content.content h6 {
color: var(--md-muted);
}
.content.content ul,
.content.content ol {
margin: 0 0 8px;
padding-left: var(--md-list-indent);
}
// A nested list belongs to the item above it, so it opens tight.
.content.content li > ul,
.content.content li > ol {
margin: 2px 0 0;
}
// The reset flattens the user-agent's own disc/circle/square progression, so the
// per-depth shapes are restated here.
.content.content ul {
list-style: disc;
}
.content.content ul ul {
list-style: circle;
}
.content.content ul ul ul {
list-style: square;
}
.content.content ol {
list-style: decimal;
}
.content.content ol ol {
list-style: lower-alpha;
}
.content.content ol ol ol {
list-style: lower-roman;
}
.content.content li {
margin: 2px 0;
}
// Markers are structure, not content. Safari below 17 ignores `::marker` colour and
// leaves them in the body colour.
.content.content li::marker {
color: var(--md-muted);
font-variant-numeric: tabular-nums;
}
// Task lists carry their own checkbox, so drop the marker and reclaim the indent.
.content.content li:has(> input[type='checkbox']) {
list-style: none;
margin-left: calc(var(--md-list-indent) * -1);
}
.content.content input[type='checkbox'] {
margin-right: 6px;
accent-color: var(--md-link);
}
.content.content a {
color: var(--md-link);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.content.content strong {
font-weight: var(--font-weight-semibold);
color: var(--md-foreground);
}
.content.content em {
font-style: italic;
}
.content.content del {
text-decoration: line-through;
color: var(--md-muted);
}
.content.content code {
padding: 1px 4px;
border-radius: 2px;
background: var(--md-surface);
font-family: var(--font-family-sf-mono);
font-size: var(--code-small-400-font-size);
color: var(--md-foreground);
}
.content.content pre {
margin: 0 0 8px;
padding: 8px 10px;
border-radius: 3px;
background: var(--md-surface);
overflow-x: auto;
@include custom-scrollbar;
code {
padding: 0;
background: none;
font-size: var(--code-small-400-font-size);
line-height: var(--line-height-18);
color: var(--md-foreground);
}
}
.content.content blockquote {
margin: 0 0 8px;
padding: 2px 0 2px 10px;
border-left: 4px solid var(--md-border);
color: var(--md-muted);
}
.content.content hr {
margin: 12px 0;
border: none;
border-top: 1px solid var(--md-border);
}
.content.content img {
max-width: 100%;
height: auto;
border-radius: 3px;
}
.content.content table {
border-collapse: collapse;
width: auto;
}
.content.content th,
.content.content td {
padding: 4px 10px;
border: 1px solid var(--md-border);
text-align: left;
}
.content.content th {
background: var(--md-surface);
font-weight: var(--font-weight-semibold);
}
// Rendered by the `table` component override.
.content.content .tableScroll {
margin: 0 0 8px;
overflow-x: auto;
@include custom-scrollbar;
}
// Prism runs with `useInlineStyles` off, so it emits `token …` class names. They are
// `:global` because CSS Modules would otherwise hash them and match nothing, and the
// palette lives here on design tokens instead of in a theme object.
.content.content :global(.token.comment),
.content.content :global(.token.prolog),
.content.content :global(.token.doctype),
.content.content :global(.token.cdata) {
color: var(--md-code-comment);
font-style: italic;
}
.content.content :global(.token.punctuation),
.content.content :global(.token.operator),
.content.content :global(.token.entity) {
color: var(--md-code-punctuation);
}
.content.content :global(.token.keyword),
.content.content :global(.token.atrule),
.content.content :global(.token.rule),
.content.content :global(.token.important),
.content.content :global(.token.selector) {
color: var(--md-code-keyword);
}
.content.content :global(.token.string),
.content.content :global(.token.char),
.content.content :global(.token.attr-value),
.content.content :global(.token.regex),
.content.content :global(.token.url) {
color: var(--md-code-string);
}
.content.content :global(.token.number),
.content.content :global(.token.boolean),
.content.content :global(.token.constant),
.content.content :global(.token.symbol) {
color: var(--md-code-number);
}
.content.content :global(.token.function),
.content.content :global(.token.class-name),
.content.content :global(.token.builtin) {
color: var(--md-code-function);
}
.content.content :global(.token.property),
.content.content :global(.token.attr-name),
.content.content :global(.token.variable),
.content.content :global(.token.tag) {
color: var(--md-code-property);
}
.content.content :global(.token.deleted) {
color: var(--text-cherry-400);
}
.content.content :global(.token.inserted) {
color: var(--text-forest-400);
}
.content.content :global(.token.bold) {
font-weight: var(--font-weight-semibold);
}
.content.content :global(.token.italic) {
font-style: italic;
}

View File

@@ -0,0 +1,84 @@
import { type ReactNode, useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import CodeBlock from './CodeBlock';
import styles from './MarkdownContent.module.scss';
/**
* SECURITY — never add `rehype-raw` here. Without it react-markdown renders raw HTML
* as plain text, so there is no `dangerouslySetInnerHTML` on the path and nothing to
* sanitise. The body is user-authored and, on a public dashboard, read anonymously;
* the shared `MarkdownRenderer` enables `rehype-raw` and is safe only for the trusted
* content it was built for. `transformLinkUri` is likewise left at its default.
*/
const REMARK_PLUGINS = [remarkGfm];
// What the default transformer substitutes for a rejected scheme. Inert, but it
// would still put `javascript:` in the DOM, so the anchor is dropped instead.
const REJECTED_HREF = `javascript:${'void(0)'}`;
const COMPONENTS: Components = {
a: ({ node: _node, children, href, ...props }): JSX.Element => {
if (!href || href === REJECTED_HREF) {
return <span {...props}>{children}</span>;
}
return (
<a {...props} href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
</a>
);
},
// Wide tables scroll inside their own box rather than widening the panel.
table: ({ node: _node, children, ...props }): JSX.Element => (
<div className={styles.tableScroll}>
<table {...props}>{children}</table>
</div>
),
code: CodeBlock,
// `CodeBlock` emits its own `pre`, so this one would nest a second one.
pre: ({ children }): JSX.Element => <>{children}</>,
};
export interface MarkdownContentProps {
/** Variable interpolation happens upstream, before parsing. */
children: string;
/** Rendered instead of the body when the source is blank. */
emptyState?: ReactNode;
className?: string;
testId?: string;
}
/** CommonMark + GFM, styled in isolation — see the reset in the SCSS module. */
function MarkdownContent({
children,
emptyState = null,
className,
testId = 'markdown-content',
}: MarkdownContentProps): JSX.Element | null {
// Dashboards re-render on every variable tick; parsing is the expensive half.
const body = useMemo(
() =>
children.trim() ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={COMPONENTS}>
{children}
</ReactMarkdown>
) : null,
[children],
);
if (!body) {
return emptyState ? <>{emptyState}</> : null;
}
return (
<div className={cx(styles.content, className)} data-testid={testId}>
{body}
</div>
);
}
export default MarkdownContent;

View File

@@ -0,0 +1,201 @@
import { render, screen, waitFor } from 'tests/test-utils';
import MarkdownContent from '../MarkdownContent';
import { loadLanguage } from '../syntaxLanguages';
describe('MarkdownContent', () => {
describe('security', () => {
it('renders a script tag as literal text, never as an element', () => {
const { container } = render(
<MarkdownContent>{'<script>alert(1)</script>'}</MarkdownContent>,
);
expect(container.querySelector('script')).toBeNull();
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'<script>alert(1)</script>',
);
});
it('renders raw HTML as text rather than markup', () => {
const { container } = render(
<MarkdownContent>{'<b>bold</b> and <img src="x" onerror="alert(1)">'}</MarkdownContent>,
);
expect(container.querySelector('b')).toBeNull();
expect(container.querySelector('img')).toBeNull();
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'<b>bold</b>',
);
});
it('drops the anchor for a javascript: href, keeping the label as text', () => {
const { container } = render(
<MarkdownContent>{'[x](javascript:alert(1))'}</MarkdownContent>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(container.innerHTML).not.toContain('javascript');
expect(screen.getByTestId('markdown-content')).toHaveTextContent('x');
});
it('opens links in a new tab without handing over the opener', () => {
render(<MarkdownContent>{'[docs](https://signoz.io)'}</MarkdownContent>);
const link = screen.getByRole('link', { name: 'docs' });
expect(link).toHaveAttribute('href', 'https://signoz.io');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
});
});
describe('CommonMark and GFM', () => {
it('renders headings, lists and emphasis', () => {
render(
<MarkdownContent>
{'# Runbook\n\n- **owner** payments\n- _rotation_ weekly'}
</MarkdownContent>,
);
expect(
screen.getByRole('heading', { level: 1, name: 'Runbook' }),
).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(2);
expect(screen.getByText('owner').tagName).toBe('STRONG');
expect(screen.getByText('rotation').tagName).toBe('EM');
});
it('renders GFM tables, task lists and strikethrough', () => {
const { container } = render(
<MarkdownContent>
{'| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- [x] done\n\n~~gone~~'}
</MarkdownContent>,
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('checkbox')).toBeChecked();
expect(container.querySelector('del')).toHaveTextContent('gone');
});
it('renders fenced code as a preformatted block', () => {
const { container } = render(
<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'kubectl get pods',
);
expect(container.querySelectorAll('pre')).toHaveLength(1);
});
it('renders malformed markdown as literal text instead of throwing', () => {
render(<MarkdownContent>{'| broken | table\n**unclosed'}</MarkdownContent>);
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'**unclosed',
);
});
});
describe('syntax highlighting', () => {
it('tokenises a fenced block once its language has loaded', async () => {
const { container } = render(
<MarkdownContent>{'```js\nconst x = 1; // note\n```'}</MarkdownContent>,
);
await waitFor(() => {
expect(container.querySelector('.token.keyword')).toHaveTextContent('const');
});
expect(container.querySelector('.token.number')).toHaveTextContent('1');
expect(container.querySelector('.token.comment')).toHaveTextContent('// note');
});
it('shows the source verbatim while the language is still loading', () => {
const { container } = render(
<MarkdownContent>{'```rust\nfn main() {}\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent('fn main() {}');
expect(container.querySelector('.token')).toBeNull();
});
it('highlights a language already loaded on the first render', async () => {
await loadLanguage('sql');
const { container } = render(
<MarkdownContent>{'```sql\nSELECT 1\n```'}</MarkdownContent>,
);
expect(container.querySelector('.token.keyword')).toHaveTextContent('SELECT');
});
it('tags the code element with the language', () => {
const { container } = render(
<MarkdownContent>{'```python\nx = 1\n```'}</MarkdownContent>,
);
expect(container.querySelector('code')).toHaveClass('language-python');
});
it('renders an unknown language verbatim', () => {
const { container } = render(
<MarkdownContent>{'```promql\nrate(foo[5m])\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent('rate(foo[5m])');
expect(container.querySelector('.token')).toBeNull();
});
it('renders a fence with no language verbatim', () => {
const { container } = render(
<MarkdownContent>{'```\nplain text\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent('plain text');
expect(container.querySelector('.token')).toBeNull();
});
it('leaves inline code untokenised', () => {
const { container } = render(
<MarkdownContent>{'use `const` here'}</MarkdownContent>,
);
expect(container.querySelector('pre')).toBeNull();
expect(container.querySelector('.token')).toBeNull();
});
});
describe('empty body', () => {
it('renders nothing when the source is blank', () => {
const { container } = render(<MarkdownContent>{' \n '}</MarkdownContent>);
expect(container).toBeEmptyDOMElement();
});
it('renders the empty state when one is supplied', () => {
render(
<MarkdownContent emptyState={<span>Nothing here yet</span>}>
{''}
</MarkdownContent>,
);
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
expect(screen.queryByTestId('markdown-content')).not.toBeInTheDocument();
});
});
});
describe('code block copy button', () => {
it('offers the block source, exactly as fenced, to the copy control', () => {
render(<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>);
const button = screen.getByTestId('text-panel-copy-code');
expect(button).toHaveAccessibleName('Copy code');
});
it('renders no copy control on inline code', () => {
render(<MarkdownContent>{'run `npm i` now'}</MarkdownContent>);
expect(
screen.queryByTestId('text-panel-copy-code'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
import { PrismLight } from 'react-syntax-highlighter';
type PrismLanguage = Parameters<typeof PrismLight.registerLanguage>[1];
type LanguageLoader = () => Promise<{ default: PrismLanguage }>;
// One dynamic import per language, so each becomes its own chunk and a panel pays
// only for the languages its fences actually name.
const LOADERS: Record<string, LanguageLoader> = {
bash: () => import('react-syntax-highlighter/dist/esm/languages/prism/bash'),
css: () => import('react-syntax-highlighter/dist/esm/languages/prism/css'),
diff: () => import('react-syntax-highlighter/dist/esm/languages/prism/diff'),
docker: () => import('react-syntax-highlighter/dist/esm/languages/prism/docker'),
go: () => import('react-syntax-highlighter/dist/esm/languages/prism/go'),
java: () => import('react-syntax-highlighter/dist/esm/languages/prism/java'),
javascript: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/javascript'),
json: () => import('react-syntax-highlighter/dist/esm/languages/prism/json'),
markup: () => import('react-syntax-highlighter/dist/esm/languages/prism/markup'),
python: () => import('react-syntax-highlighter/dist/esm/languages/prism/python'),
rust: () => import('react-syntax-highlighter/dist/esm/languages/prism/rust'),
sql: () => import('react-syntax-highlighter/dist/esm/languages/prism/sql'),
typescript: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/typescript'),
yaml: () => import('react-syntax-highlighter/dist/esm/languages/prism/yaml'),
};
const ALIASES: Record<string, string> = {
dockerfile: 'docker',
html: 'markup',
js: 'javascript',
py: 'python',
sh: 'bash',
shell: 'bash',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
};
const registered = new Set<string>();
const inFlight = new Map<string, Promise<boolean>>();
/** The name Prism knows a fence's language by, or null if it knows none. */
export function resolveLanguage(name: string): string | null {
const canonical = ALIASES[name] ?? name;
return canonical in LOADERS ? canonical : null;
}
export function isLanguageRegistered(name: string): boolean {
return registered.has(name);
}
/**
* Resolves to whether `name` is registered and ready to highlight with. Concurrent
* callers share one import, so a dashboard of same-language fences fetches once.
*/
export function loadLanguage(name: string): Promise<boolean> {
if (registered.has(name)) {
return Promise.resolve(true);
}
const pending = inFlight.get(name);
if (pending) {
return pending;
}
const loader = LOADERS[name];
if (!loader) {
return Promise.resolve(false);
}
const request = loader()
.then((module) => {
PrismLight.registerLanguage(name, module.default);
registered.add(name);
return true;
})
.catch(() => false)
.finally(() => {
inFlight.delete(name);
});
inFlight.set(name, request);
return request;
}
export default PrismLight;

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
/**
* Registers `language` with Prism on demand, reporting when it is ready to
* highlight with. Already-loaded languages report ready on the first render, so a
* second fence of the same language never flashes unhighlighted.
*/
export function usePrismLanguage(language: string | null): boolean {
const [isReady, setIsReady] = useState(
() => !!language && isLanguageRegistered(language),
);
useEffect(() => {
if (!language) {
setIsReady(false);
return undefined;
}
if (isLanguageRegistered(language)) {
setIsReady(true);
return undefined;
}
setIsReady(false);
let isStale = false;
// `loadLanguage` resolves false rather than rejecting, so there is no failure
// path here beyond leaving the block unhighlighted.
void loadLanguage(language).then((loaded): boolean => {
if (!isStale && loaded) {
setIsReady(true);
}
return loaded;
});
return (): void => {
isStale = true;
};
}, [language]);
return isReady;
}

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
@@ -20,6 +23,13 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,8 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import {
buildBaseConfig,
minStepInterval,
type TimeAxisChromeArgs,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
@@ -19,7 +17,6 @@ import {
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
@@ -31,22 +28,12 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
export interface BuildTimeSeriesConfigArgs extends TimeAxisChromeArgs {
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/** Builds a `UPlotConfigBuilder` for a TimeSeries panel: shared scaffolding plus one series per result. */
@@ -66,7 +53,7 @@ export function buildTimeSeriesConfig({
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
isDarkMode,
timezone,
panelMode,

View File

@@ -0,0 +1,26 @@
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;

View File

@@ -0,0 +1,34 @@
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: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: NO_PANEL_ACTIONS,
};

View File

@@ -5,6 +5,7 @@ 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,
@@ -22,8 +23,24 @@ 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;
return (
(PANELS[kind] as RenderablePanelDefinition | undefined) ?? UNSUPPORTED_PANEL
);
}

View File

@@ -1,4 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
/**
@@ -18,3 +21,30 @@ 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;
}

View File

@@ -5,7 +5,10 @@ import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
@@ -39,6 +42,24 @@ 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;
@@ -50,6 +71,8 @@ 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). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}

View File

@@ -1,7 +1,7 @@
import { buildDefaultQueries } from '../buildDefaultQueries';
describe('buildDefaultQueries', () => {
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
it('seeds a list panel with a runnable logs query ordered by timestamp desc', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
expect(queries).toHaveLength(1);
@@ -13,7 +13,7 @@ describe('buildDefaultQueries', () => {
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds a List panel without a limit so it pages server-side by default', () => {
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.
@@ -21,7 +21,7 @@ describe('buildDefaultQueries', () => {
expect(spec.limit).toBeUndefined();
});
it('seeds no query for non-List kinds (they seed from the builder)', () => {
it('seeds no query for plotted kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
});

View File

@@ -3,7 +3,6 @@ import type {
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
@@ -26,7 +25,11 @@ import {
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
/**
* 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;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
@@ -56,6 +59,18 @@ export interface BuildBaseConfigArgs {
onClick?: OnClickPluginOpts['onClick'];
}
/** What a kind's build args pass straight through; the rest is derived from its spec. */
export type PanelChromeArgs = Pick<
BuildBaseConfigArgs,
'panelId' | 'isDarkMode' | 'timezone' | 'panelMode'
>;
export type TimeAxisChromeArgs = PanelChromeArgs &
Pick<
BuildBaseConfigArgs,
'stepIntervals' | 'minTimeScale' | 'maxTimeScale' | 'onDragSelect' | 'onClick'
>;
/**
* Builds the panel-agnostic scaffolding of a uPlot chart (scales, thresholds,
* axes, drag-to-zoom, click plugin). Callers then `addSeries`/`addPlugin` on the
@@ -63,7 +78,7 @@ export interface BuildBaseConfigArgs {
*/
export function buildBaseConfig({
panelId,
panelType,
isTimeAxis,
isDarkMode,
timezone,
panelMode,
@@ -133,7 +148,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
panelType,
isTimeAxis,
});
builder.addAxis({
@@ -143,7 +158,6 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -1,14 +1,15 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
import { listViewInitialLogQuery } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
/** Seed query for a new panel. Only a list panel 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): DashboardtypesQueryDTO[] {
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
if (kind !== 'signoz/ListPanel') {
return [];
}
return [];
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
}

View File

@@ -1,7 +1,10 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
@@ -50,15 +53,22 @@ 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('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -67,7 +77,7 @@ function Panel({
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
data-panel-visible={isOffScreen ? 'false' : 'true'}
// Stable locator so the "Download as PNG" action can find this node to
// capture, without threading a ref through the header/actions chain.
data-panel-root={panelId}
@@ -85,25 +95,23 @@ function Panel({
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
{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}
/>
)}
<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>
);

View File

@@ -1,64 +0,0 @@
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;

View File

@@ -162,7 +162,9 @@ describe('useCreateAlertFromPanel', () => {
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
expect.objectContaining({
queries: panel.spec.queries,
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: expect.objectContaining({
requestType: 'time_series',
}),
variables: { service: { type: 'query', value: 'checkout' } },
}),
);

View File

@@ -83,6 +83,7 @@ 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,
...eventMeta,
});

View File

@@ -15,6 +15,7 @@ import { buildQueryRangeRequest } from 'pages/DashboardPage/DashboardContainer/q
import { envelopesToQuery } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -47,11 +48,15 @@ export function useCreateAlertFromPanel(): (
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
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];
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'createAlerts',
panelType,
panelKind,
...eventMeta,
widgetId: panelId,
queryType: getPanelQueryType(panel),
@@ -65,7 +70,7 @@ export function useCreateAlertFromPanel(): (
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
panelType,
queryCapabilities: getPanelDefinition(panelKind).queryCapabilities,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,

View File

@@ -44,6 +44,7 @@ export function useDeletePanel({
}
const removed = section.items.find((i) => i.id === panelId);
const removedKind = removed?.panel?.spec.plugin.kind;
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchAsync([
@@ -52,9 +53,15 @@ export function useDeletePanel({
]);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'delete',
panelType: removed?.panel
? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind]
: undefined,
// An item ref can outlive its panel, so both fields go on together or
// not at all: `panelType` keeps existing reports resolving, `panelKind`
// is the V2 identity.
...(removedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[removedKind],
panelKind: removedKind,
}
: {}),
panelId,
...eventMeta,
});

View File

@@ -43,6 +43,7 @@ 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]);
}

View File

@@ -128,11 +128,14 @@ export function useDrilldown(
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType });
void logEvent(DashboardDetailEvents.DrilldownOpened, {
panelType,
panelKind: kind,
});
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick, panelType],
[onClick, panelType, kind],
);
const handleClose = useCallback((): void => {
@@ -176,7 +179,8 @@ export function useDrilldown(
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelType,
panelKind: kind,
queryCapabilities: getPanelDefinition(kind).queryCapabilities,
v1Query,
enabled: showAggregateMenu,
});

View File

@@ -55,6 +55,7 @@ export function useMovePanelToSection({
if (!moved) {
return;
}
const movedKind = moved.panel?.spec.plugin.kind;
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Land at the section bottom, not backfilled into a gap — least disruptive
@@ -73,9 +74,15 @@ export function useMovePanelToSection({
);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'move',
panelType: moved.panel
? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind]
: undefined,
// An item ref can outlive its panel, so both fields go on together or
// not at all: `panelType` keeps existing reports resolving, `panelKind`
// is the V2 identity.
...(movedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[movedKind],
panelKind: movedKind,
}
: {}),
panelId,
...eventMeta,
});

View File

@@ -3,11 +3,15 @@ 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 { PANEL_TYPES } from 'constants/queryBuilder';
import { buildQueryRangeRequest } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -15,7 +19,9 @@ import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
panelKind: PanelKind;
/** The panel kind's declared query capabilities — shapes the substitution request. */
queryCapabilities: PanelQueryCapabilities;
/** 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). */
@@ -38,7 +44,8 @@ interface UseResolvedDrilldownQueryResult {
*/
export function useResolvedDrilldownQuery({
queries,
panelType,
panelKind,
queryCapabilities,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
@@ -60,7 +67,7 @@ export function useResolvedDrilldownQuery({
substituteVars({
data: buildQueryRangeRequest({
queries,
panelType,
queryCapabilities,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
@@ -70,7 +77,7 @@ export function useResolvedDrilldownQuery({
enabled,
hasVariables,
queries,
panelType,
queryCapabilities,
minTime,
maxTime,
variables,
@@ -81,8 +88,13 @@ export function useResolvedDrilldownQuery({
if (!hasVariables || !data) {
return v1Query;
}
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
// 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 { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,7 +1,11 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -54,6 +58,23 @@ 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_CAPABILITIES: PanelQueryCapabilities = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
};
const LIST_PANEL_CAPABILITIES: PanelQueryCapabilities = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
};
function builderPanel(): DashboardtypesPanelDTO {
return panelWith('signoz/TimeSeriesPanel', {
name: 'A',
@@ -100,7 +121,13 @@ beforeEach(() => {
describe('usePanelQuery', () => {
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.schemaVersion).toBe('v1');
expect(requestPayload.compositeQuery.queries).toStrictEqual([
@@ -112,30 +139,30 @@ describe('usePanelQuery', () => {
});
it('converts redux nanosecond time to epoch ms on the request', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_000_000_000);
expect(requestPayload.end).toBe(2_000_000_000);
});
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) => {
// 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', () => {
renderHook(() =>
usePanelQuery({
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.requestType).toBe(requestType);
expect(requestPayload.requestType).toBe('raw');
});
it('exposes the raw V5 response, request payload, and legend map on data', () => {
@@ -148,7 +175,11 @@ describe('usePanelQuery', () => {
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
expect(result.current.data.response).toBe(v5Response);
@@ -158,7 +189,11 @@ describe('usePanelQuery', () => {
it('exposes an undefined response before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
expect(result.current.data.response).toBeUndefined();
});
@@ -171,7 +206,11 @@ describe('usePanelQuery', () => {
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
expect(result.current.error?.message).toBe('boom');
});
@@ -186,7 +225,11 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(true);
@@ -200,7 +243,11 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
expect(result.current.isLoading).toBe(true);
});
@@ -213,14 +260,23 @@ describe('usePanelQuery', () => {
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to the fetch hook when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
enabled: false,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -228,7 +284,12 @@ describe('usePanelQuery', () => {
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
usePanelQuery({
panel: emptyPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
enabled: true,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -243,6 +304,7 @@ describe('usePanelQuery', () => {
aggregations: [{}],
}),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
@@ -251,7 +313,13 @@ 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' }));
renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toStrictEqual(
expect.arrayContaining([
@@ -270,6 +338,7 @@ describe('usePanelQuery', () => {
renderHook(() =>
usePanelQuery({
panel,
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelId: 'p1',
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
}),
@@ -296,6 +365,7 @@ describe('usePanelQuery', () => {
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
}),
);
@@ -316,7 +386,11 @@ 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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.pageSize).toBe(25);
@@ -327,20 +401,34 @@ 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' }),
usePanelQuery({
panel: listPanel({ limit: 100 }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
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' }));
renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
act(() => result.current.pagination?.setPageSize(50));
@@ -380,7 +468,11 @@ 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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
expect(result.current.pagination?.pageIndex).toBe(0);
expect(result.current.pagination?.canPrev).toBe(false);
@@ -392,21 +484,33 @@ 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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
expect(withCursor.result.current.pagination?.canNext).toBe(true);
});
@@ -416,7 +520,13 @@ 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' }));
const { result } = renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
expect(result.current.pagination?.pageIndex).toBe(0);
act(() => result.current.pagination?.goNext());
@@ -428,7 +538,11 @@ 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' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.canNext).toBe(false);
@@ -437,7 +551,11 @@ describe('usePanelQuery', () => {
it('ignores a non-positive page size so paging never goes invalid', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
act(() => result.current.pagination?.setPageSize(0));
expect(result.current.pagination?.pageSize).toBe(25);
@@ -456,14 +574,26 @@ describe('usePanelQuery', () => {
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
withAutoRefreshDisabled(true);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
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' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});

View File

@@ -3,7 +3,6 @@ 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,
@@ -24,7 +23,7 @@ import {
queryReferencesAnyVariable,
} from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
@@ -38,6 +37,8 @@ const DEFAULT_LIST_PAGE_SIZE = 25;
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities` at the call site. */
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.
@@ -85,21 +86,20 @@ 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 list query with an explicit `limit` shows without a server pager; without
// one it pages server-side at a user-selectable size.
// 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.
const hasExplicitLimit = useMemo(
() => !!getBuilderQueries(queries)[0]?.limit,
[queries],
);
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit;
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
const [offset, setOffset] = useState(0);
@@ -188,7 +188,7 @@ export function usePanelQuery({
() =>
buildQueryRangeRequest({
queries,
panelType,
queryCapabilities,
startMs,
endMs,
fillGaps,
@@ -197,7 +197,7 @@ export function usePanelQuery({
}),
[
queries,
panelType,
queryCapabilities,
startMs,
endMs,
fillGaps,

View File

@@ -1,12 +1,13 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
type DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
buildQueryRangeRequest,
extractLegendMap,
getBarStepIntervalSeconds,
hasRunnableQueries,
panelTypeToRequestType,
toQueryEnvelopes,
} from '../buildQueryRangeRequest';
@@ -40,20 +41,46 @@ function compositeQuery(
const HOUR_MS = 60 * 60 * 1000;
const START_MS = 1_700_000_000_000;
describe('panelTypeToRequestType', () => {
// 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_CAPABILITIES = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
};
const BAR_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
bucketedStepInterval: true,
};
const TABLE_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
};
const LIST_PANEL_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
};
describe('requestType', () => {
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);
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_CAPABILITIES, requestType },
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request.requestType).toBe(requestType);
});
});
@@ -135,7 +162,7 @@ describe('buildQueryRangeRequest', () => {
it('assembles the full request DTO', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: TIME_SERIES_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -157,7 +184,7 @@ describe('buildQueryRangeRequest', () => {
it('sets formatTableResultForUI only for TABLE panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TABLE,
queryCapabilities: TABLE_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -167,7 +194,7 @@ describe('buildQueryRangeRequest', () => {
it('passes through fillGaps into formatOptions', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: TIME_SERIES_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
fillGaps: true,
@@ -178,7 +205,7 @@ describe('buildQueryRangeRequest', () => {
it('stamps offset/limit onto builder queries when pagination is given', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_PANEL_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
pagination: { offset: 100, limit: 50 },
@@ -198,7 +225,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' }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_PANEL_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -218,7 +245,7 @@ describe('buildQueryRangeRequest', () => {
signal: 'logs',
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
}),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_PANEL_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -238,7 +265,7 @@ describe('buildQueryRangeRequest', () => {
];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_PANEL_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -252,7 +279,7 @@ describe('buildQueryRangeRequest', () => {
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_PANEL_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -265,7 +292,7 @@ describe('buildQueryRangeRequest', () => {
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
panelType: PANEL_TYPES.BAR,
queryCapabilities: BAR_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -280,7 +307,7 @@ describe('buildQueryRangeRequest', () => {
it('preserves a user-set stepInterval on BAR builder queries', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
panelType: PANEL_TYPES.BAR,
queryCapabilities: BAR_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -293,7 +320,7 @@ describe('buildQueryRangeRequest', () => {
it('does not touch stepInterval for non-BAR panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: TIME_SERIES_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});

View File

@@ -7,7 +7,12 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
import {
envelopesToQuery,
fromPerses,
panelTypeToRequestType,
toPerses,
} from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -21,6 +26,23 @@ 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);

View File

@@ -14,9 +14,9 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
// 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,31 +29,6 @@ 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
@@ -239,7 +214,13 @@ function withPagination(
export interface BuildQueryRangeRequestArgs {
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
/**
* The panel kind's declared query capabilities (`PanelDefinition.queryCapabilities`): 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;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
@@ -258,7 +239,12 @@ export interface BuildQueryRangeRequestArgs {
*/
export function buildQueryRangeRequest({
queries,
panelType,
queryCapabilities: {
requestType,
formatTableResultForUI,
bucketedStepInterval,
orderTiebreaker,
},
startMs,
endMs,
fillGaps = false,
@@ -266,10 +252,10 @@ export function buildQueryRangeRequest({
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (panelType === PANEL_TYPES.BAR) {
if (bucketedStepInterval) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
if (panelType === PANEL_TYPES.LIST) {
if (orderTiebreaker) {
envelopes = withListOrderTiebreaker(envelopes);
}
if (pagination) {
@@ -280,10 +266,10 @@ export function buildQueryRangeRequest({
schemaVersion: 'v1',
start: startMs,
end: endMs,
requestType: panelTypeToRequestType(panelType),
requestType,
compositeQuery: { queries: envelopes },
formatOptions: {
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
formatTableResultForUI,
fillGaps,
},
variables,

View File

@@ -10,6 +10,7 @@ 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';
@@ -20,10 +21,7 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
panelTypeToRequestType,
toQueryEnvelopes,
} from './buildQueryRangeRequest';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
/**
* Adapters between the V2 perses query shape and the V1 `Query` the shared query
@@ -90,6 +88,33 @@ 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

View File

@@ -40,6 +40,7 @@ function PublicPanel({
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
queryCapabilities: panelDefinition.queryCapabilities,
panelKey,
publicDashboardId,
startMs,

View File

@@ -1,6 +1,9 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
@@ -42,6 +45,15 @@ 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,
},
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,

View File

@@ -3,10 +3,9 @@ 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/DashboardPage/DashboardContainer/hooks/useGetQueryRangeV5';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { PanelQueryCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
buildQueryRangeRequest,
extractLegendMap,
@@ -21,6 +20,8 @@ import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities`. */
queryCapabilities: PanelQueryCapabilities;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
@@ -52,15 +53,13 @@ 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;
@@ -77,13 +76,13 @@ export function usePublicPanelQuery({
() =>
buildQueryRangeRequest({
queries,
panelType,
queryCapabilities,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, panelType, startMs, endMs, fillGaps],
[queries, queryCapabilities, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);