Compare commits

...

11 Commits

Author SHA1 Message Date
Abhi Kumar
0d062b7db1 fix(dashboards): name the text visualisation in PANEL_TYPES
`PANEL_KIND_TO_PANEL_TYPE` had no answer for a kind without a V1 counterpart, so
`toLegacyPanelType` defaulted to TIME_SERIES. That default was reachable: deleting,
moving or cloning a text panel reported `panelType: 'graph'` alongside
`panelKind: 'signoz/TextPanel'`, and the editor handed 'graph' to the kind switcher
for a text panel.

`PANEL_TYPES` is not V1 legacy — it is the vocabulary the query builder, the
explorers and the alert builder all use for "how is this query visualised", and 215
of its 246 consumers are outside dashboards. A text visualisation belongs in it, so
`TEXT` is simply true where the default was a lie.

The map goes back to a total Record and the default is gone; no call site changes.
Five maps keyed exhaustively by panel type gain an entry, each of them honest: no
query and no explorer view for a text panel, no V1 chart or wrapper component (v2
forks on the kind's mode long before those), and no full-view legend table.

Assisted-by: Claude Opus 5
2026-09-07 14:08:38 +05:30
Abhi Kumar
78c3abffea feat(dashboards): let the text panel hide its header
New Panel header config section writing plugin.spec.headerOptions.hide
(carried across kind switches; absent = show, the API zero value). On the
grid a hidden header swaps for hover-revealed controls - a centered drag
pill and the floating actions menu - so the panel stays movable and
editable; the public view and previews just drop the strip.

Assisted-by: Claude Fable 5
(cherry picked from commit 1c98f112ab)
2026-09-05 18:19:44 +05:30
Abhi Kumar
8b13dcebf7 refactor(dashboards): share panel chrome across authoring modes
PanelHeader and the View modal toolbar become mode-discriminated unions, so
static call sites stop fabricating empty query props. The card shell is owned
once by Panel/PublicPanel, the static preview by one StaticPreviewPane, and
the static View modal reuses the query modal's layout (editor in the
query-builder slot, preview below). Transparent panels drop the header
divider, and the public view now honours transparency.

Assisted-by: Claude Fable 5
(cherry picked from commit a20c5ff01d)
2026-09-05 18:19:36 +05:30
Abhi Kumar
5cef64fa7a chore(dashboards): regenerate the api contract for text header options
Assisted-by: Claude Fable 5
(cherry picked from commit b5747c42c4)
2026-09-05 18:18:53 +05:30
Naman Verma
754af55668 feat: add header options to text panel
(cherry picked from commit 39b2edacc2)
2026-09-05 18:18:53 +05:30
Abhi Kumar
1f7d3e5d42 feat(dashboards): flag a text panel body that continues below the fold
A "Scroll for more" pill — the changelog modal's affordance, pulsing chevrons
and all — floats at the bottom of the body whenever content extends past the
fold, hides near the end, and jumps to the bottom on click. It renders as a
sibling of the scrollport rather than inside it, so it stays put as the body
scrolls and sits outside the markdown body's style isolation.

Overflow is re-measured on scroll, on container resize and on every commit, so
the pill follows the live preview as the body is typed.

Assisted-by: Claude Fable 5
(cherry picked from commit 05d3da96c6)
2026-09-05 18:18:53 +05:30
Abhi Kumar
87c22492aa feat(dashboards): rewrite text panel bodies on variable rename
Phase 5 of the Text panel plan (first slice). Renaming a dashboard variable
rewrote every query that referenced it but silently orphaned `{{tokens}}` in
text panel bodies (TDD D5 calls this out explicitly). The impact flow now
treats a markdown body as one more reference-bearing text:

- the usage scanner surfaces static panels' bodies (all four token syntaxes,
  via the same rewriter queries use) as "Markdown body" rows in the impact
  dialog, editable before applying like any other usage;
- applying patches `plugin/spec/text` directly — a static panel's queries stay
  `[]`, never touched;
- delete leaves the body for review, matching raw PromQL/ClickHouse.

Also pins the static editor's save shape with a component test: Save strips
any stray query and submits `queries: []`, the only shape the API accepts.

(cherry picked from commit ccfa2f0a04)
2026-09-05 18:18:53 +05:30
Abhi Kumar
97b3ed7d33 feat(dashboards): add the Text panel
Phase 4 of the Text panel plan — the first static kind, and the phase that
makes it reachable:

- signoz/TextPanel registered: Markdown renderer (variable interpolation before
  parsing, all four syntaxes, dotted names included and `$__` macros excluded,
  undefined left literal) over the isolated-style MarkdownContent; the shared
  MarkdownEditor as its editor pane, with the dashboard's variables in the
  insert menu.
- Creation flow: picker tile, new-panel route gated on the registry (the legacy
  map would reject a kind it doesn't carry), kind switcher never disables a
  query-less kind, and a new Text panel seeds presentation defaults with
  `queries: []`.
- Text layout config section (alignment + solid/transparent background) writing
  the `presentation` slice. Middle and bottom alignment use auto margins rather
  than `justify-content`, which pushes overflow above the scrollport where no
  scroll position reaches it; right and center move the table block and list
  markers too, since `text-align` only moves inline content.
- No context links section: they resolve against query fields at click-time,
  and a text body has neither.
- The preview pane does not scroll. It uses the query preview's structure —
  dotted canvas wrapper owning the spacing, card flexed and clipped inside —
  and the renderer scrolls its own body when content outgrows it, as on the
  grid; that internal scroll uses the shared custom-scrollbar mixin.
- The transparent option drops the panel card's background and border (TDD D7)
  while header actions stay reachable, and reaches the editor preview and the
  View modal, not just the grid card (TDD §6.4's preview edge case): all three
  static hosts read it through one isTransparentPanel util instead of inlining
  the plugin-spec cast.
- Generated API types refreshed; TextPanel enters PanelKind, so the legacy
  PANEL_TYPES map turns partial and V1-era surfaces (alerts, drilldown, CSV,
  explorer export) read it through toLegacyPanelType, whose fallback is
  unreachable behind their capability gates.
- Explorer exports cannot target a static kind: the seed guard ignores the
  exported query and creates a plain panel instead.

Assisted-by: Claude Fable 5
(cherry picked from commit f248e84155)
2026-09-05 18:18:39 +05:30
Naman Verma
28116079dc feat: add spec for text panel
(cherry picked from commit e6ac27e0be)
2026-09-05 18:16:21 +05:30
Abhi Kumar
405dbbea2e 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-05 18:15:39 +05:30
Abhi Kumar
b582f96a2d 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-05 18:15:39 +05:30
115 changed files with 4895 additions and 422 deletions

View File

@@ -3064,6 +3064,11 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3401,6 +3406,11 @@ components:
- kind
- spec
type: object
DashboardtypesPanelBackground:
enum:
- solid
- transparent
type: string
DashboardtypesPanelFormatting:
properties:
decimalPrecision:
@@ -3421,6 +3431,7 @@ components:
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
@@ -3431,6 +3442,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3441,6 +3453,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3514,6 +3527,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
properties:
kind:
enum:
- signoz/TextPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
properties:
kind:
@@ -3805,6 +3830,36 @@ components:
- color
- columnName
type: object
DashboardtypesTextAlign:
enum:
- left
- center
- right
type: string
DashboardtypesTextMode:
enum:
- markdown
type: string
DashboardtypesTextPanelSpec:
properties:
headerOptions:
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
mode:
$ref: '#/components/schemas/DashboardtypesTextMode'
presentation:
$ref: '#/components/schemas/DashboardtypesTextPresentation'
text:
type: string
type: object
DashboardtypesTextPresentation:
properties:
background:
$ref: '#/components/schemas/DashboardtypesPanelBackground'
textAlign:
$ref: '#/components/schemas/DashboardtypesTextAlign'
verticalAlign:
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
@@ -4014,6 +4069,12 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:

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

@@ -4402,6 +4402,58 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesListPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
'signoz/TextPanel' = 'signoz/TextPanel',
}
export interface DashboardtypesHeaderOptionsDTO {
/**
* @type boolean
*/
hide?: boolean;
}
export enum DashboardtypesTextModeDTO {
markdown = 'markdown',
}
export enum DashboardtypesPanelBackgroundDTO {
solid = 'solid',
transparent = 'transparent',
}
export enum DashboardtypesTextAlignDTO {
left = 'left',
center = 'center',
right = 'right',
}
export enum DashboardtypesVerticalAlignDTO {
top = 'top',
center = 'center',
bottom = 'bottom',
}
export interface DashboardtypesTextPresentationDTO {
background?: DashboardtypesPanelBackgroundDTO;
textAlign?: DashboardtypesTextAlignDTO;
verticalAlign?: DashboardtypesVerticalAlignDTO;
}
export interface DashboardtypesTextPanelSpecDTO {
headerOptions?: DashboardtypesHeaderOptionsDTO;
mode?: DashboardtypesTextModeDTO;
presentation?: DashboardtypesTextPresentationDTO;
/**
* @type string
*/
text?: string;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
/**
* @enum signoz/TextPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
spec: DashboardtypesTextPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -4409,7 +4461,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -5331,6 +5384,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
}
/**
* @nullable

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

@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -363,6 +363,7 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}
@@ -610,6 +611,8 @@ export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
// No query of its own: a text panel renders an authored body.
[PANEL_TYPES.TEXT]: initialQueriesMap.metrics,
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
};

View File

@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
BAR: true,
PIE: false,
HISTOGRAM: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -14,6 +14,8 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.LIST]: ListPanelWrapper,
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
text: 'Markdown body',
};
interface VariableImpactDialogProps {

View File

@@ -0,0 +1,49 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { buildVariableImpactPatch } from '../utils/variableImpactPatch';
import type { VariableUsage } from '../utils/variableUsages';
jest.mock('../variableAdapters', () => ({
formModelToDto: (model: unknown): unknown => model,
}));
const dashboard = {
spec: {
panels: {
runbook: {
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: 'env {{svc}}' } },
queries: [],
},
},
},
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const textUsage: VariableUsage = {
id: 'panel:runbook:0',
sourceType: 'panel',
sourceId: 'runbook',
sourceLabel: 'Runbook',
kind: 'text',
envelopeIndex: 0,
currentText: 'env {{svc}}',
resultingText: 'env {{zone}}',
};
describe('buildVariableImpactPatch — text panel bodies', () => {
it('replaces the plugin-spec text, never the (empty) queries', () => {
const ops = buildVariableImpactPatch(dashboard, [], [textUsage]);
const panelOps = ops.filter((op) => op.path.includes('/panels/'));
expect(panelOps).toStrictEqual([
{
op: 'replace',
path: '/spec/panels/runbook/spec/plugin/spec/text',
value: 'env {{zone}}',
},
]);
});
});

View File

@@ -45,6 +45,16 @@ function promqlPanel(name: string, query: string): unknown {
};
}
function textPanel(name: string, text: string): unknown {
return {
spec: {
display: { name },
plugin: { kind: 'signoz/TextPanel', spec: { text } },
queries: [],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
@@ -99,6 +109,38 @@ describe('findVariableUsages', () => {
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
describe('text panel bodies (TDD D5)', () => {
const textDash = dashboard(
{
runbook: textPanel(
'Runbook',
'env {{svc}} / {{.svc}} / [[svc]] / $svc / {{svcx}}',
),
unrelated: textPanel('Plain', 'no tokens here'),
},
[variable({ name: 'svc', type: 'QUERY' })],
);
it('finds the body usage and skips bodies without the token', () => {
const usages = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usages.map((u) => u.id)).toStrictEqual(['panel:runbook:0']);
expect(usages[0].kind).toBe('text');
expect(usages[0].sourceLabel).toBe('Runbook');
});
it('rewrites all four syntaxes on rename, leaving other names alone', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usage.resultingText).toBe(
'env {{zone}} / {{.zone}} / [[zone]] / $zone / {{svcx}}',
);
});
it('leaves the body for review on delete', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'delete');
expect(usage.resultingText).toBe(usage.currentText);
});
});
});
describe('findApplyUsages', () => {

View File

@@ -0,0 +1,16 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { isQuerylessPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
/**
* The markdown body of a static (query-less) panel, or null for query kinds.
* One localized cast: the plugin-spec union can't be narrowed by a dynamic kind.
*/
export function getTextPanelBody(
spec: DashboardtypesPanelSpecDTO | undefined,
): string | null {
if (!spec?.plugin || !isQuerylessPanelKind(spec.plugin.kind)) {
return null;
}
const { text } = spec.plugin.spec as { text?: string };
return typeof text === 'string' ? text : null;
}

View File

@@ -107,6 +107,18 @@ export function buildVariableImpactPatch(
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
// A static kind's usage edits its markdown body, not a query.
const textUsage = list.find((usage) => usage.kind === 'text');
if (textUsage) {
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/plugin/spec/text`,
value: textUsage.resultingText,
});
return;
}
if (!panel?.spec?.queries?.length) {
return;
}

View File

@@ -12,6 +12,7 @@ import {
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
/** The kind of query text a variable is referenced from. */
@@ -19,7 +20,8 @@ export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
| 'variable'
| 'text';
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
@@ -81,7 +83,7 @@ function computeResultingText(
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableFromExpression(text, variableName)
: text;
@@ -106,6 +108,31 @@ export function findVariableUsages(
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
// A static kind references variables from its body, not a query (TDD D5 —
// rename must rewrite text bodies too, or it silently orphans the tokens).
const textBody = getTextPanelBody(panel?.spec);
if (typeof textBody === 'string') {
if (textContainsVariableReference(textBody, variableName)) {
usages.push({
id: `panel:${panelId}:0`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: 'text',
envelopeIndex: 0,
currentText: textBody,
resultingText: computeResultingText(
'text',
textBody,
variableName,
mode,
newName,
),
});
}
return;
}
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;

View File

@@ -2,6 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isQuerylessPanelKind,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
@@ -37,6 +38,12 @@ export function getPanelTypeDisabledReason({
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
// A kind that renders without a query pairs with anything — it declares no
// query types or signals, and the checks below would read that as "supports
// nothing" and disable it everywhere.
if (isQuerylessPanelKind(kind)) {
return undefined;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}

View File

@@ -23,6 +23,8 @@ import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppea
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -117,6 +119,23 @@ export const SECTION_REGISTRY: {
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
},
[SectionKind.TextLayout]: {
Component: TextLayoutSection,
get: (spec): SectionSpecMap[SectionKind.TextLayout] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
update: (spec, presentation): PanelSpec =>
updatePluginSlice(spec, 'presentation', presentation),
},
[SectionKind.PanelHeader]: {
Component: PanelHeaderSection,
get: (spec): SectionSpecMap[SectionKind.PanelHeader] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.PanelHeader]>(
spec,
'headerOptions',
),
update: (spec, headerOptions): PanelSpec =>
updatePluginSlice(spec, 'headerOptions', headerOptions),
},
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.

View File

@@ -0,0 +1,24 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
function PanelHeaderSection({
value,
onChange,
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
return (
<ConfigSwitch
testId="panel-header-hide"
title="Hide header"
description="Drop the title strip on the dashboard; hovering the panel brings it back for drag and actions."
value={value?.hide === true}
onChange={(hide): void => onChange({ ...value, hide })}
/>
);
}
export default PanelHeaderSection;

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import PanelHeaderSection from '../PanelHeaderSection';
describe('PanelHeaderSection', () => {
it('toggles hide on', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={undefined} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: true });
});
it('toggles hide back off', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={{ hide: true }} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: false });
});
it('shows the header by default when the slice is empty', () => {
render(<PanelHeaderSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-header-hide')).not.toBeChecked();
});
});

View File

@@ -0,0 +1,11 @@
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,76 @@
import {
DashboardtypesPanelBackgroundDTO,
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './TextLayoutSection.module.scss';
const HORIZONTAL_OPTIONS = [
{ value: DashboardtypesTextAlignDTO.left, label: 'Left' },
{ value: DashboardtypesTextAlignDTO.center, label: 'Center' },
{ value: DashboardtypesTextAlignDTO.right, label: 'Right' },
];
const VERTICAL_OPTIONS = [
{ value: DashboardtypesVerticalAlignDTO.top, label: 'Top' },
{ value: DashboardtypesVerticalAlignDTO.center, label: 'Middle' },
{ value: DashboardtypesVerticalAlignDTO.bottom, label: 'Bottom' },
];
/**
* Edits the Text panel's `presentation` slice: body alignment and the
* solid/transparent card background (TDD D7 — transparency is scoped to the
* text spec, not the panel envelope).
*/
function TextLayoutSection({
value,
onChange,
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
return (
<div className={styles.section}>
<div className={styles.field}>
<Typography.Text>Horizontal alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-horizontal-align"
items={HORIZONTAL_OPTIONS}
value={value?.textAlign ?? DashboardtypesTextAlignDTO.left}
onChange={(textAlign): void => onChange({ ...value, textAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Vertical alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-vertical-align"
items={VERTICAL_OPTIONS}
value={value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top}
onChange={(verticalAlign): void => onChange({ ...value, verticalAlign })}
/>
</div>
<ConfigSwitch
testId="text-layout-transparent"
title="Transparent panel"
description="Drop the card background and border — for section headers on the canvas."
value={value?.background === DashboardtypesPanelBackgroundDTO.transparent}
onChange={(transparent): void =>
onChange({
...value,
background: transparent
? DashboardtypesPanelBackgroundDTO.transparent
: DashboardtypesPanelBackgroundDTO.solid,
})
}
/>
</div>
);
}
export default TextLayoutSection;

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
DashboardtypesPanelBackgroundDTO,
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import TextLayoutSection from '../TextLayoutSection';
const value = {
textAlign: DashboardtypesTextAlignDTO.left,
verticalAlign: DashboardtypesVerticalAlignDTO.top,
background: DashboardtypesPanelBackgroundDTO.solid,
};
describe('TextLayoutSection', () => {
it('changes horizontal alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Center'));
expect(onChange).toHaveBeenCalledWith({
...value,
textAlign: DashboardtypesTextAlignDTO.center,
});
});
it('changes vertical alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Bottom'));
expect(onChange).toHaveBeenCalledWith({
...value,
verticalAlign: DashboardtypesVerticalAlignDTO.bottom,
});
});
it('toggles the transparent background', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByTestId('text-layout-transparent'));
expect(onChange).toHaveBeenCalledWith({
...value,
background: DashboardtypesPanelBackgroundDTO.transparent,
});
});
it('defaults to left/top/solid when the slice is empty', () => {
render(<TextLayoutSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('text-layout-horizontal-align')).toBeInTheDocument();
expect(screen.getByTestId('text-layout-transparent')).not.toBeChecked();
});
});

View File

@@ -26,16 +26,3 @@
background: var(--l2-border);
}
}
// The static editor's preview: the panel card the grid shows, minus actions.
.staticPreviewSurface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}

View File

@@ -23,7 +23,7 @@ import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
import { toLegacyPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -60,7 +60,7 @@ function PanelEditorQueryBuilder({
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
const panelType = toLegacyPanelType(panelDefinition.kind);
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';

View File

@@ -95,6 +95,7 @@ function PreviewPane({
})}
>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={data}

View File

@@ -8,7 +8,7 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
@@ -217,7 +217,7 @@ function QueryEditorBody({
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
panelType: toLegacyPanelType(panelKind),
query: currentQuery,
spec: draft.spec,
});

View File

@@ -1,11 +1,8 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -15,12 +12,11 @@ import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import StaticPreviewPane from './StaticPreviewPane/StaticPreviewPane';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
@@ -98,22 +94,12 @@ function StaticEditorBody({
/>
}
preview={
<div className={styles.staticPreviewSurface}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
panelDefinition={panelDefinition}
panel={draft}
panelId={panelId}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
</div>
<StaticPreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
}
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
config={

View File

@@ -0,0 +1,33 @@
// Same dotted backdrop and spacing as the query preview. The pane itself never
// scrolls — the panel card clips, and the renderer scrolls its own body when the
// content outgrows it, as on the grid.
.preview {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 24px;
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
background-size: 20px 20px;
border-bottom: 1px solid var(--l1-border);
overflow: hidden;
}
// The draft panel, rendered as the card the grid shows (minus actions).
.surface {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}
// The preview honours the draft's transparent option (TDD §6.4): the card chrome
// drops and the body sits on the dotted canvas, as it will on the dashboard.
.transparent {
background: transparent;
border-color: transparent;
}

View File

@@ -0,0 +1,52 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { PanelMode } from 'lib/visualization/panels/types';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import type { RenderableStaticPanelDefinition } from '../../Panels/types/panelDefinition';
import { isPanelHeaderHidden } from '../../Panels/utils/isPanelHeaderHidden';
import { isTransparentPanel } from '../../Panels/utils/isTransparentPanel';
import styles from './StaticPreviewPane.module.scss';
interface StaticPreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
panelDefinition: RenderableStaticPanelDefinition;
panelMode: PanelMode;
}
/**
* Live preview of a static draft: the panel card rendered through the same
* `StaticPanelBody` the grid uses, on the query preview's dotted canvas. It
* re-renders from the draft spec on every edit — no query, no Run step. Shared
* by the full editor and the View modal.
*/
function StaticPreviewPane({
panelId,
panel,
panelDefinition,
panelMode,
}: StaticPreviewPaneProps): JSX.Element {
return (
<div className={styles.preview}>
<div
className={cx(styles.surface, {
[styles.transparent]: isTransparentPanel(panel.spec),
})}
>
{!isPanelHeaderHidden(panel.spec) && (
<PanelHeader mode="static" panelId={panelId} panel={panel} hideActions />
)}
<StaticPanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
panelMode={panelMode}
/>
</div>
</div>
);
}
export default StaticPreviewPane;

View File

@@ -0,0 +1,129 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import StaticEditorBody from '../StaticEditorBody';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorSave } from '../hooks/usePanelEditorSave';
jest.mock('../hooks/usePanelEditorSave', () => ({
usePanelEditorSave: jest.fn(),
}));
// Chrome + collaborators stubbed: this suite asserts the static body's wiring —
// the save shape above all — not their internals.
jest.mock('../Header/Header', () => ({
__esModule: true,
default: ({ onSave }: { onSave: () => void }): JSX.Element => (
<button type="button" data-testid="header-save" onClick={onSave}>
Save
</button>
),
}));
jest.mock('../ConfigPane/ConfigPane', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="config-pane" />,
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="static-preview-body" />,
}),
);
jest.mock('@signozhq/ui/sonner', () => ({ toast: { success: jest.fn() } }));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): unknown => ({ showErrorModal: jest.fn() }),
}));
const mockUseSave = usePanelEditorSave as jest.Mock;
// The draft deliberately carries a stray query: Save must strip it — the API
// rejects anything but [] for a static kind.
const draft = {
kind: 'Panel',
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hi' } },
queries: [{ spec: {} }],
},
} as unknown as DashboardtypesPanelDTO;
const draftApi: PanelEditorDraftApi = {
draft,
spec: draft.spec,
setSpec: jest.fn(),
isSpecDirty: false,
reset: jest.fn(),
};
const definition = ({
kind: 'signoz/TextPanel',
displayName: 'Text',
sections: [],
actions: {},
mode: 'static',
Renderer: (): null => null,
EditorPane: (): JSX.Element => <div data-testid="editor-pane" />,
} as unknown) as RenderableStaticPanelDefinition;
function renderBody(isEditable = true): void {
render(
<StaticEditorBody
dashboardId="d1"
panelId="p1"
panel={draft}
isEditable={isEditable}
editDisabledReason=""
onClose={jest.fn()}
onSaved={jest.fn()}
draftApi={draftApi}
panelDefinition={definition}
onChangePanelKind={jest.fn()}
/>,
);
}
describe('StaticEditorBody', () => {
beforeEach(() => {
mockUseSave.mockReset();
mockUseSave.mockReturnValue({
save: jest.fn().mockResolvedValue('p1'),
isSaving: false,
});
});
it('renders the editor pane and the live preview, no query builder', () => {
renderBody();
expect(screen.getByTestId('editor-pane')).toBeInTheDocument();
expect(screen.getByTestId('static-preview-body')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-query-builder'),
).not.toBeInTheDocument();
});
it('saves the spec with queries forced to [] — the only shape the API accepts', async () => {
const save = jest.fn().mockResolvedValue('p1');
mockUseSave.mockReturnValue({ save, isSaving: false });
renderBody();
fireEvent.click(screen.getByTestId('header-save'));
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(save).toHaveBeenCalledWith({ ...draft.spec, queries: [] });
});
it('does not save when the dashboard is not editable', () => {
const save = jest.fn();
mockUseSave.mockReturnValue({ save, isSaving: false });
renderBody(false);
fireEvent.click(screen.getByTestId('header-save'));
expect(save).not.toHaveBeenCalled();
});
});

View File

@@ -115,3 +115,15 @@ describe('newPanelRoute', () => {
});
});
});
describe('parseNewPanelKind — kinds without a legacy panel type', () => {
it('accepts a registered static kind', () => {
expect(parseNewPanelKind('new', '?panelKind=signoz%2FTextPanel')).toBe(
'signoz/TextPanel',
);
});
it('still rejects a kind that is not registered', () => {
expect(parseNewPanelKind('new', '?panelKind=signoz%2FNopePanel')).toBeNull();
});
});

View File

@@ -7,7 +7,7 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
@@ -90,7 +90,7 @@ export function usePanelEditSession({
// Hosts fork on `definition.mode` before mounting this session (the editor and
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = toLegacyPanelType(panelKind);
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({

View File

@@ -23,7 +23,7 @@ import {
resolveQueryType,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
toLegacyPanelType,
type PanelKind,
} from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
@@ -113,7 +113,7 @@ export function usePanelTypeSwitch({
builderQuery: query,
});
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
const newPanelType = toLegacyPanelType(newKind);
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
@@ -45,7 +45,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
panelType: toLegacyPanelType(panelKind),
setSpec: draftApi.setSpec,
});

View File

@@ -4,8 +4,8 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PANELS } from '../Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
@@ -40,7 +40,9 @@ export function parseNewPanelKind(
return null;
}
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
// Gated on the registry, not the legacy map — a static kind has no legacy
// panel type, and the map would reject its route as a stale link.
return kind && kind in PANELS ? (kind as PanelKind) : null;
}
/**

View File

@@ -34,6 +34,8 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
// Static kind: no query surface at all.
'signoz/TextPanel': [],
};
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
@@ -45,11 +47,16 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TablePanel': [metrics, logs, traces],
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
'signoz/TextPanel': [],
};
// 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> = {
// Partial: a static kind declares no query capabilities — the lookup below
// resolves undefined on both sides for it.
const EXPECTED_QUERY_CAPABILITIES: Partial<
Record<PanelKind, PanelQueryCapabilities>
> = {
'signoz/TimeSeriesPanel': {
requestType: time_series,
formatTableResultForUI: false,

View File

@@ -8,7 +8,7 @@ import {
selectViewPanelExtendWindow,
useViewPanelStore,
} from '../../../store/useViewPanelStore';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../types/panelKind';
import { toLegacyPanelType } from '../../types/panelKind';
import PanelLoader from '../PanelLoader/PanelLoader';
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
import { useExtendTimeWindow } from './useExtendTimeWindow';
@@ -57,7 +57,7 @@ function NoData({
// `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 panelType = toLegacyPanelType(panelKind);
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel

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,4 @@
.pane {
height: 100%;
min-height: 0;
}

View File

@@ -0,0 +1,70 @@
import { useCallback, useMemo } from 'react';
import MarkdownEditor from 'components/MarkdownEditor/MarkdownEditor';
import type { EditorVariable } from 'components/MarkdownEditor/types';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import type { StaticEditorPaneProps } from '../../types/panelDefinition';
import type {
DashboardtypesPanelSpecDTO,
DashboardtypesTextPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import styles from './EditorPane.module.scss';
/**
* The Text panel's authoring pane — the Markdown source editor in the slot where
* query-backed kinds show the query builder. The preview above renders the draft
* spec, so it updates live as the body changes; there is no Run step.
*/
function EditorPane({
spec,
onChangeSpec,
}: StaticEditorPaneProps): JSX.Element {
// The plugin-spec union can't be narrowed by a dynamic kind; one localized cast,
// as in the section registry's lenses.
const pluginSpec = spec.plugin.spec as DashboardtypesTextPanelSpecDTO;
const { variables: variableDtos } = useDashboardFetchRequired();
const variables = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto))
.flatMap((model): EditorVariable[] =>
model.name ? [{ name: model.name, badge: model.type }] : [],
),
[variableDtos],
);
const onChangeText = useCallback(
(text: string): void => {
// Written back through the same cast: `plugin` is the kind-discriminated
// union, and TS resolves a bare object literal against the wrong arm.
const nextPluginSpec: DashboardtypesTextPanelSpecDTO = {
...pluginSpec,
text,
};
onChangeSpec({
...spec,
plugin: {
...spec.plugin,
spec: nextPluginSpec,
} as DashboardtypesPanelSpecDTO['plugin'],
});
},
[spec, pluginSpec, onChangeSpec],
);
return (
<div className={styles.pane} data-testid="text-panel-editor-pane">
<MarkdownEditor
value={pluginSpec.text ?? ''}
onChange={onChangeText}
variables={variables}
statusHint="Preview updates as you type"
/>
</div>
);
}
export default EditorPane;

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,69 @@
@use '../../../../../../styles/scrollbar' as *;
.panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 8px 12px;
overflow: auto;
@include custom-scrollbar;
}
// Horizontal alignment inherits into the rendered body, which deliberately leaves
// `text-align` alone so the panel can own it.
.alignLeft {
text-align: left;
}
.alignCenter {
text-align: center;
}
.alignRight {
text-align: right;
}
// `text-align` moves only inline content: the table is a block box inside its
// scroll wrapper and stays put, and list markers hang at the list's left edge.
// (0,2,1) beats the body reset at (0,2,0); `list-style-position` goes on the
// `li` directly because the body's `list-style` shorthand on `ul`/`ol` resets
// the inherited position.
.panel.alignRight table {
margin-left: auto;
}
.panel.alignCenter table {
margin-left: auto;
margin-right: auto;
}
.panel.alignRight li,
.panel.alignCenter li {
list-style-position: inside;
}
.alignTop {
justify-content: flex-start;
}
// Auto margins, not `justify-content`: when the body overflows, an auto margin
// resolves to zero so the content's top stays scrollable — `center`/`flex-end`
// push the overflow above the scrollport, where no scroll position reaches it.
// Specificity (0,3,0): the body root's `all: revert` reset sits at (0,2,0) and
// would strip a tied margin rule.
.panel.alignMiddle > *:first-child {
margin-top: auto;
margin-bottom: auto;
}
.panel.alignBottom > *:first-child {
margin-top: auto;
}
// Positioning context for the scroll-to-bottom pill floating over the body.
.host {
position: relative;
height: 100%;
min-height: 0;
}

View File

@@ -0,0 +1,77 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { StaticRendererProps } from '../../types/rendererProps';
import { interpolateVariables } from './interpolateVariables';
import MarkdownContent from './MarkdownContent';
import ScrollToBottomPill from './ScrollToBottomPill';
import { useOverflowBelow } from './useOverflowBelow';
import styles from './Renderer.module.scss';
const HORIZONTAL_ALIGN_CLASS: Record<DashboardtypesTextAlignDTO, string> = {
[DashboardtypesTextAlignDTO.left]: styles.alignLeft,
[DashboardtypesTextAlignDTO.center]: styles.alignCenter,
[DashboardtypesTextAlignDTO.right]: styles.alignRight,
};
const VERTICAL_ALIGN_CLASS: Record<DashboardtypesVerticalAlignDTO, string> = {
[DashboardtypesVerticalAlignDTO.top]: styles.alignTop,
[DashboardtypesVerticalAlignDTO.center]: styles.alignMiddle,
[DashboardtypesVerticalAlignDTO.bottom]: styles.alignBottom,
};
/**
* Renders the panel's own Markdown body. The first kind that issues no query, so it
* reads nothing from `data` and has no loading or error state — malformed Markdown
* renders as literal text rather than throwing.
*/
function Renderer({
panel,
dashboardId,
}: StaticRendererProps<'signoz/TextPanel'>): JSX.Element {
const { text, presentation } = panel.spec.plugin.spec;
const variables = useDashboardStore(
selectResolvedVariables(dashboardId ?? ''),
);
// Interpolate and parse together: a dashboard re-renders on every variable tick,
// and re-parsing every text panel on each one is the cost worth avoiding.
const body = useMemo(
() => interpolateVariables(text ?? '', variables),
[text, variables],
);
const { scrollRef, hasMoreBelow, scrollToBottom } =
useOverflowBelow<HTMLDivElement>();
return (
<div className={styles.host}>
<div
ref={scrollRef}
className={cx(
styles.panel,
HORIZONTAL_ALIGN_CLASS[
presentation?.textAlign ?? DashboardtypesTextAlignDTO.left
],
VERTICAL_ALIGN_CLASS[
presentation?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top
],
)}
data-testid="text-panel"
>
<MarkdownContent>{body}</MarkdownContent>
</div>
{hasMoreBelow && <ScrollToBottomPill onClick={scrollToBottom} />}
</div>
);
}
export default Renderer;

View File

@@ -0,0 +1,37 @@
.pill {
all: unset;
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 12px 4px 10px;
background-color: var(--l1-border);
border-radius: 20px;
cursor: pointer;
transition: background-color 0.1s;
color: var(--l2-foreground);
span {
font-size: 12px;
line-height: 18px;
color: var(--l2-foreground);
}
svg {
animation: scroll-pill-pulse 1s infinite;
}
}
@keyframes scroll-pill-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,26 @@
import { ChevronsDown } from '@signozhq/icons';
import styles from './ScrollToBottomPill.module.scss';
interface ScrollToBottomPillProps {
onClick: () => void;
}
/**
* Floating affordance signalling content below the fold; click jumps to the end.
*/
function ScrollToBottomPill({ onClick }: ScrollToBottomPillProps): JSX.Element {
return (
<button
type="button"
className={styles.pill}
onClick={onClick}
data-testid="text-panel-scroll-more"
>
<ChevronsDown size={14} />
<span>Scroll for more</span>
</button>
);
}
export default ScrollToBottomPill;

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,81 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { PanelMode } from 'lib/visualization/panels/types';
import Renderer from '../Renderer';
const panel = {
kind: 'Panel',
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hello' } },
queries: [],
},
} as unknown as PanelOfKind<'signoz/TextPanel'>;
/** jsdom has no layout: stub the scroll geometry the hook reads. */
function setScrollGeometry(
el: HTMLElement,
{ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number },
): void {
Object.defineProperty(el, 'scrollHeight', {
configurable: true,
value: scrollHeight,
});
Object.defineProperty(el, 'clientHeight', {
configurable: true,
value: clientHeight,
});
}
function renderPanel(): HTMLElement {
render(
<Renderer
panelId="p1"
panel={panel}
panelMode={PanelMode.DASHBOARD_VIEW}
/>,
);
return screen.getByTestId('text-panel');
}
describe('Text panel scroll-to-bottom pill', () => {
it('is absent when the body fits', () => {
const scroller = renderPanel();
setScrollGeometry(scroller, { scrollHeight: 100, clientHeight: 100 });
fireEvent.scroll(scroller);
expect(screen.queryByTestId('text-panel-scroll-more')).not.toBeInTheDocument();
});
it('appears when content extends below the fold and jumps to the end on click', () => {
const scroller = renderPanel();
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
act(() => {
fireEvent.scroll(scroller);
});
const pill = screen.getByTestId('text-panel-scroll-more');
const scrollTo = jest.fn();
scroller.scrollTo = scrollTo;
fireEvent.click(pill);
expect(scrollTo).toHaveBeenCalledWith({ top: 400, behavior: 'smooth' });
});
it('hides once the user reaches the bottom', () => {
const scroller = renderPanel();
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
act(() => {
fireEvent.scroll(scroller);
});
expect(screen.getByTestId('text-panel-scroll-more')).toBeInTheDocument();
scroller.scrollTop = 300;
act(() => {
fireEvent.scroll(scroller);
});
expect(screen.queryByTestId('text-panel-scroll-more')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,56 @@
import { interpolateVariables } from '../interpolateVariables';
const variables = {
env: { value: 'prod' },
service: { value: ['checkout', 'cart'] },
count: { value: 3 },
};
describe('interpolateVariables', () => {
it.each([
['{{env}}', 'prod'],
['{{.env}}', 'prod'],
['[[env]]', 'prod'],
['$env', 'prod'],
])('substitutes the %s syntax', (token, expected) => {
expect(interpolateVariables(`env is ${token}.`, variables)).toBe(
`env is ${expected}.`,
);
});
it('substitutes a dotted name in the $ syntax', () => {
const dotted = { 'service.name': { value: 'checkout' } };
expect(interpolateVariables('svc is $service.name.', dotted)).toBe(
'svc is checkout.',
);
});
it('leaves $__ macros alone', () => {
expect(interpolateVariables('every $__interval', variables)).toBe(
'every $__interval',
);
});
it('joins list values with a comma', () => {
expect(interpolateVariables('on {{service}}', variables)).toBe(
'on checkout, cart',
);
});
it('stringifies numeric values', () => {
expect(interpolateVariables('n={{count}}', variables)).toBe('n=3');
});
it('leaves an undefined variable as literal text', () => {
expect(interpolateVariables('see {{missing}} and $nope', variables)).toBe(
'see {{missing}} and $nope',
);
});
it('injects values as content, not markup boundaries', () => {
const hostile = { env: { value: '**bold** <script>x</script>' } };
expect(interpolateVariables('{{env}}', hostile)).toBe(
'**bold** <script>x</script>',
);
});
});

View File

@@ -0,0 +1,26 @@
import { Type } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import EditorPane from './EditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/TextPanel'> = {
kind: 'signoz/TextPanel',
displayName: 'Text',
icon: Type,
sections,
mode: 'static',
Renderer,
EditorPane,
actions: {
view: true,
edit: true,
clone: true,
// Nothing tabular or chart-like to export; the body is already the readable form.
download: { csv: false, png: false, svg: false },
createAlert: false,
search: false,
drilldown: false,
},
};

View File

@@ -0,0 +1,51 @@
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
// The four syntaxes a dashboard body may carry. Group 1 covers `{{name}}` and
// `{{.name}}`, group 2 `[[name]]`, group 3 `$name`. The `$` form takes dotted
// names (`$service.name`) but not a trailing dot, so a sentence-ending period
// stays prose; `$__…` macros are excluded, as in the query reference engine.
const VARIABLE_PATTERN =
/\{\{\s*\.?([\w.-]+)\s*\}\}|\[\[\s*([\w.-]+)\s*\]\]|\$(?!__)([A-Za-z_]\w*(?:\.\w+)*)/g;
// Variable values arrive as scalars or lists of them; anything else is not
// something a reader would want spliced into prose.
function formatValue(value: unknown): string | null {
if (Array.isArray(value)) {
return value.map((entry) => formatValue(entry) ?? '').join(', ');
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value.toString();
}
return null;
}
/**
* Substitutes dashboard variables into a Markdown body before it is parsed, so an
* injected value becomes Markdown *content* rather than markup — dynamic-variable
* values come from telemetry and are attacker-influenceable.
*
* An undefined variable is left as literal text, matching how queries treat one.
*/
export function interpolateVariables(
text: string,
variables: Querybuildertypesv5QueryRangeRequestDTOVariables | undefined,
): string {
if (!text || !variables) {
return text;
}
return text.replace(
VARIABLE_PATTERN,
(match, braced?: string, bracketed?: string, dollar?: string): string => {
const name = braced ?? bracketed ?? dollar;
if (!name) {
return match;
}
const formatted = formatValue(variables[name]?.value);
return formatted ?? match;
},
);
}

View File

@@ -0,0 +1,13 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
// No thresholds, legend, axes or formatting: there is no data to threshold, scale
// or format. No context links either — they resolve against query fields at
// click-time, and a text body has neither.
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true },
},
{ kind: SectionKind.TextLayout },
{ kind: SectionKind.PanelHeader },
];

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,67 @@
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
// Within this distance of the end counts as "at the bottom", so the pill isn't
// kept alive by sub-pixel rounding or a trailing margin.
const BOTTOM_EPSILON_PX = 16;
interface UseOverflowBelowResult<T extends HTMLElement> {
scrollRef: RefObject<T>;
/** Content extends below the fold and the user isn't at the bottom yet. */
hasMoreBelow: boolean;
scrollToBottom: () => void;
}
/**
* Tracks whether a scroll container has unseen content below the fold. Re-measures
* on scroll, on container resize, and on every commit — the cheap way to follow
* content growth (a live preview re-rendering as the body is typed) without
* observing the subtree.
*/
export function useOverflowBelow<
T extends HTMLElement,
>(): UseOverflowBelowResult<T> {
const scrollRef = useRef<T>(null);
const [hasMoreBelow, setHasMoreBelow] = useState(false);
const measure = useCallback((): void => {
const el = scrollRef.current;
if (!el) {
return;
}
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
setHasMoreBelow(remaining > BOTTOM_EPSILON_PX);
}, []);
// No deps on purpose: runs after every commit. setState bails on unchanged
// values, so this settles instead of looping.
useEffect(() => {
measure();
});
useEffect(() => {
const el = scrollRef.current;
if (!el) {
return undefined;
}
el.addEventListener('scroll', measure, { passive: true });
const observer = new ResizeObserver(measure);
observer.observe(el);
return (): void => {
el.removeEventListener('scroll', measure);
observer.disconnect();
};
}, [measure]);
const scrollToBottom = useCallback((): void => {
const el = scrollRef.current;
el?.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, []);
return { scrollRef, hasMoreBelow, scrollToBottom };
}

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

@@ -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 { definition as Text } from './kinds/TextPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelDefinition,
@@ -23,6 +24,7 @@ export const PANELS: PanelRegistry = {
[PieChart.kind]: PieChart,
[Histogram.kind]: Histogram,
[List.kind]: List,
[Text.kind]: Text,
};
export type PanelOption = Pick<

View File

@@ -8,6 +8,13 @@ import type { DashboardtypesPanelPluginKindDTO } from 'api/generated/services/si
*/
export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
/**
* Every kind's counterpart in `PANEL_TYPES`, the vocabulary the query builder,
* explorers and alerts all speak. Total by construction: a new kind fails to compile
* here until it declares which visualisation it is, and a kind whose visualisation
* `PANEL_TYPES` doesn't name yet is a signal to add it there rather than to pick a
* near-enough value.
*/
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
@@ -16,12 +23,24 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
'signoz/TextPanel': PANEL_TYPES.TEXT,
};
/**
* The `PANEL_TYPES` a kind maps to, for the query, alert and drilldown surfaces that
* speak that vocabulary. A total lookup — every kind has an answer, so there is
* nothing to default and no call site can be handed a visualisation that isn't its
* own.
*/
export function toLegacyPanelType(kind: PanelKind): PANEL_TYPES {
return PANEL_KIND_TO_PANEL_TYPE[kind];
}
/**
* Reverse of {@link PANEL_KIND_TO_PANEL_TYPE} — the mapping is a bijection, so every
* panel kind round-trips. Partial because `PANEL_TYPES` also has types with no V2 kind
* (e.g. trace/empty); a lookup on those returns `undefined`.
* panel kind round-trips. Partial in this direction because `PANEL_TYPES` also names
* visualisations with no dashboard kind (trace, empty); a lookup on those is
* `undefined`.
*/
export const PANEL_TYPE_TO_PANEL_KIND: Partial<Record<PANEL_TYPES, PanelKind>> =
Object.fromEntries(

View File

@@ -3,12 +3,14 @@ import type {
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesHeaderOptionsDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTableFormattingDTO,
DashboardtypesTableThresholdDTO,
DashboardtypesTextPresentationDTO,
DashboardtypesThresholdWithLabelDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
@@ -21,10 +23,12 @@ import {
Hash,
Link2,
Palette,
PanelTop,
PencilRuler,
Scale3D,
Signpost,
Wallpaper,
AlignLeft,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
@@ -51,6 +55,8 @@ export enum SectionKind {
Thresholds = 'thresholds',
ContextLinks = 'contextLinks',
Columns = 'columns',
TextLayout = 'presentation',
PanelHeader = 'headerOptions',
}
/**
@@ -93,6 +99,8 @@ export interface SectionSpecMap {
[SectionKind.Thresholds]: AnyThreshold[]; // spec.plugin.spec.thresholds (variant picks the editor)
[SectionKind.ContextLinks]: DashboardtypesLinkDTO[]; // spec.links (PANEL-level)
[SectionKind.Columns]: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
[SectionKind.TextLayout]: DashboardtypesTextPresentationDTO; // spec.plugin.spec.presentation (Text)
[SectionKind.PanelHeader]: DashboardtypesHeaderOptionsDTO; // spec.plugin.spec.headerOptions (Text)
}
/**
@@ -140,7 +148,11 @@ export interface SectionControls {
export type ControlledSectionKind = keyof SectionControls;
/** Atomic sections — no sub-controls; a kind either shows them or not. */
export type AtomicSectionKind = SectionKind.ContextLinks | SectionKind.Columns;
export type AtomicSectionKind =
| SectionKind.ContextLinks
| SectionKind.Columns
| SectionKind.TextLayout
| SectionKind.PanelHeader;
/** Predicate to hide a section from the current spec; returning true removes it. */
export type SectionVisibilityPredicate = (
@@ -173,6 +185,8 @@ export const SECTION_METADATA = {
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
[SectionKind.TextLayout]: { title: 'Text layout', icon: AlignLeft },
[SectionKind.PanelHeader]: { title: 'Panel header', icon: PanelTop },
} as const satisfies Record<SectionKind, SectionMetadata>;
/**

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schem
import { listViewInitialLogQuery } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
import { toLegacyPanelType, type PanelKind } from '../types/panelKind';
/** 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. */
@@ -11,5 +11,5 @@ export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
return [];
}
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
return toPerses(listViewInitialLogQuery, toLegacyPanelType(kind));
}

View File

@@ -4,9 +4,12 @@ import {
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPanelBackgroundDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTextAlignDTO,
DashboardtypesTimePreferenceDTO,
DashboardtypesVerticalAlignDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -35,6 +38,10 @@ export interface SeededPluginSpec {
>;
selectFields?: SectionSpecMap[SectionKind.Columns];
thresholds?: AnyThreshold[];
presentation?: SectionSpecMap[SectionKind.TextLayout];
headerOptions?: SectionSpecMap[SectionKind.PanelHeader];
/** Text panel body. Not a config section — the editor's main pane owns it. */
text?: string;
}
export interface SeedContext {
@@ -114,6 +121,33 @@ function isEmptySlice(value: object): boolean {
}
const SECTION_SEEDS: SectionSeeds = {
[SectionKind.TextLayout]: {
specKey: 'presentation',
// Explicit defaults (not the API's implicit ones) so the alignment controls
// open on a value, and the body carries across a kind switch and back.
seed: (
_controls,
{ oldPluginSpec },
): SectionSpecMap[SectionKind.TextLayout] => ({
textAlign:
oldPluginSpec?.presentation?.textAlign ?? DashboardtypesTextAlignDTO.left,
verticalAlign:
oldPluginSpec?.presentation?.verticalAlign ??
DashboardtypesVerticalAlignDTO.top,
background:
oldPluginSpec?.presentation?.background ??
DashboardtypesPanelBackgroundDTO.solid,
}),
},
[SectionKind.PanelHeader]: {
specKey: 'headerOptions',
// Only an active opt-out carries; absent = show (the API's zero value).
seed: (
_controls,
{ oldPluginSpec },
): SectionSpecMap[SectionKind.PanelHeader] =>
oldPluginSpec?.headerOptions?.hide ? { hide: true } : {},
},
[SectionKind.Visualization]: {
specKey: 'visualization',
seed: (

View File

@@ -3,7 +3,7 @@ import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { toLegacyPanelType } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
/**
@@ -28,5 +28,5 @@ export function getPanelBuilderQuery(
if (panel.spec.queries.length === 0 && defaultSignal) {
return initialQueriesMap[defaultSignal];
}
return fromPerses(panel.spec.queries, PANEL_KIND_TO_PANEL_TYPE[kind]);
return fromPerses(panel.spec.queries, toLegacyPanelType(kind));
}

View File

@@ -0,0 +1,17 @@
import type {
DashboardtypesHeaderOptionsDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Whether the panel opted out of its header strip (`headerOptions.hide`) —
* one localized cast over the plugin-spec union, as `isTransparentPanel`.
*/
export function isPanelHeaderHidden(spec: DashboardtypesPanelSpecDTO): boolean {
const headerOptions = (
spec.plugin.spec as {
headerOptions?: DashboardtypesHeaderOptionsDTO;
}
).headerOptions;
return headerOptions?.hide === true;
}

View File

@@ -0,0 +1,20 @@
import {
DashboardtypesPanelBackgroundDTO,
type DashboardtypesPanelSpecDTO,
type DashboardtypesTextPresentationDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Whether the panel opted out of its card chrome (TDD D7). The card is an
* ancestor of the renderer, so hosts read this one plugin-spec field — the
* accepted smell; the union can't be narrowed by a dynamic kind, hence one
* localized cast for every host.
*/
export function isTransparentPanel(spec: DashboardtypesPanelSpecDTO): boolean {
const presentation = (spec.plugin.spec as {
presentation?: DashboardtypesTextPresentationDTO;
}).presentation;
return (
presentation?.background === DashboardtypesPanelBackgroundDTO.transparent
);
}

View File

@@ -1,4 +1,5 @@
.panel {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -8,3 +9,50 @@
border-radius: 4px;
overflow: hidden;
}
// `headerOptions.hide`: hover floats in the drag pill + actions menu, so a
// headerless panel can still be moved and edited.
.hiddenHeaderControls {
position: absolute;
inset: 0 0 auto;
z-index: 2;
height: 36px;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
background: linear-gradient(to bottom, var(--l2-background), transparent);
}
// The open-menu case: the dropdown is a portal, so hovering it un-hovers the
// panel — without this the trigger fades while its menu stays up.
.panel:hover .hiddenHeaderControls,
.hiddenHeaderControls:focus-within,
.hiddenHeaderControls:has([data-state='open']) {
opacity: 1;
pointer-events: auto;
}
.dragPill {
width: 64px;
height: 6px;
border-radius: 999px;
background: var(--l2-border);
cursor: grab;
}
.floatingActions {
position: absolute;
top: 4px;
right: 8px;
}
// D7: a static kind may drop the card so its body sits on the dashboard canvas
// (section headers). Chrome only — hover actions in the header stay reachable.
.transparent {
background: transparent;
border-color: transparent;
}

View File

@@ -1,9 +1,15 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
import { isTransparentPanel } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isTransparentPanel';
import type { DashboardSection } from '../../utils';
import QueryPanel from './QueryPanel';
import StaticPanel from './StaticPanel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelHeader from './PanelHeader/PanelHeader';
import QueryPanelContent from './QueryPanelContent';
import StaticPanelBody from './StaticPanelBody/StaticPanelBody';
import styles from './Panel.module.scss';
/**
* Layout context for the panel actions menu — present only in editable mode. No
@@ -24,8 +30,9 @@ interface PanelProps {
}
/**
* A single dashboard panel. Forks on the kind's mode before any query machinery
* exists, so a static kind never mounts a fetch — not even a disabled one.
* A single dashboard panel: shared shell chrome, forking on the kind's mode
* before any query machinery exists, so a static kind never mounts a fetch —
* not even a disabled one.
*/
function Panel({
panel,
@@ -35,26 +42,56 @@ function Panel({
}: PanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
if (panelDefinition.mode === 'static') {
return (
<StaticPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
);
}
return (
<QueryPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
<div
className={cx(styles.panel, {
[styles.transparent]: isTransparentPanel(panel.spec),
})}
data-panel-visible={isVisible === false ? '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}
>
{panelDefinition.mode === 'static' ? (
<>
{isPanelHeaderHidden(panel.spec) ? (
<div className={styles.hiddenHeaderControls}>
<span
className={cx('panel-drag-handle', styles.dragPill)}
data-testid="hidden-header-drag-handle"
/>
<div className={styles.floatingActions}>
<PanelActionsMenu
panelId={panelId}
panel={panel}
panelActions={panelActions}
/>
</div>
</div>
) : (
<PanelHeader
mode="static"
panelId={panelId}
panel={panel}
panelActions={panelActions}
/>
)}
<StaticPanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
/>
</>
) : (
<QueryPanelContent
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
)}
</div>
);
}

View File

@@ -14,7 +14,7 @@ interface PanelActionsMenuProps {
/** The panel itself — seeds "Create Alerts" and the download filename. */
panel: DashboardtypesPanelDTO;
/** The panel's query response — the source for "Download as CSV". */
data: PanelQueryData;
data?: PanelQueryData;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}

View File

@@ -42,7 +42,7 @@ interface UsePanelActionItemsArgs {
/** The panel itself — seeds "Create Alerts" and the download filename. */
panel: DashboardtypesPanelDTO;
/** The panel's query response — the source for "Download as CSV". */
data: PanelQueryData;
data?: PanelQueryData;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}

View File

@@ -8,6 +8,11 @@
min-height: 32px;
}
// D7: when the panel drops its card chrome, the header's divider goes with it.
.transparent {
border-bottom-color: transparent;
}
.headerLeft {
display: flex;
align-items: center;

View File

@@ -9,6 +9,8 @@ import cx from 'classnames';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { isTransparentPanel } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isTransparentPanel';
import type { PanelActionsConfig } from '../Panel';
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
import PanelHeaderSearch from './PanelHeaderSearch';
@@ -21,10 +23,22 @@ import {
import styles from './PanelHeader.module.scss';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
interface PanelHeaderBaseProps {
panelId: string;
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
/**
* Suppress the actions menu entirely — for the editor preview, where
* panel-level actions don't apply (some survive their gates without
* `panelActions`, so omitting it isn't enough).
*/
hideActions?: boolean;
}
interface QueryPanelHeaderProps extends PanelHeaderBaseProps {
mode: 'query';
/** The panel's query response — the menu's source for "Download as CSV". */
data: PanelQueryData;
/** Background refresh in flight — shows a spinner without blinking the chart. */
@@ -35,44 +49,35 @@ interface PanelHeaderProps {
warning?: WarningDTO;
/** Per-panel time-preference label; null when it follows the dashboard window. */
timeLabel?: PanelTimePreferenceLabel | null;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
/** Kind declares header search — renders the box. */
searchable?: boolean;
/** Current search term; shell owns it, the renderer applies the filter. */
searchTerm?: string;
/** Pushes a new search term up to the shell. */
onSearchChange?: (value: string) => void;
/**
* Suppress the actions menu entirely — for the editor preview, where
* panel-level actions don't apply (some survive their gates without
* `panelActions`, so omitting it isn't enough).
*/
hideActions?: boolean;
}
interface StaticPanelHeaderProps extends PanelHeaderBaseProps {
mode: 'static';
}
type PanelHeaderProps = QueryPanelHeaderProps | StaticPanelHeaderProps;
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
panelId,
panel,
data,
isFetching,
error,
warning,
timeLabel,
panelActions,
searchable,
searchTerm = '',
onSearchChange,
hideActions,
}: PanelHeaderProps): JSX.Element {
function PanelHeader(props: PanelHeaderProps): JSX.Element {
const { panelId, panel, panelActions, hideActions } = props;
const query = props.mode === 'query' ? props : null;
const name = panel.spec.display.name;
const description = panel.spec.display.description;
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
const errorDetail = useMemo(
() => panelStatusFromError(query?.error),
[query?.error],
);
const warningDetail = useMemo(
() => panelStatusFromWarning(warning),
[warning],
() => panelStatusFromWarning(query?.warning),
[query?.warning],
);
// Client-derived: warn a Number panel that has more than one enabled query (#9512).
@@ -97,7 +102,11 @@ function PanelHeader({
}
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div
className={cx(styles.header, 'panel-drag-handle', {
[styles.transparent]: isTransparentPanel(panel.spec),
})}
>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
{description && (
@@ -113,7 +122,7 @@ function PanelHeader({
/>
</TooltipSimple>
)}
{isFetching && (
{query?.isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
@@ -124,13 +133,16 @@ function PanelHeader({
{/* `panel-no-drag` opts this region out of the drag handle so clicks hit
the controls instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{searchable && onSearchChange && (
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
{query?.searchable && query.onSearchChange && (
<PanelHeaderSearch
value={query.searchTerm ?? ''}
onChange={query.onSearchChange}
/>
)}
{timeLabel && (
<TooltipSimple title={timeLabel.full} arrow>
{query?.timeLabel && (
<TooltipSimple title={query.timeLabel.full} arrow>
<span className={styles.timePill} data-testid="panel-time-preference">
{timeLabel.short}
{query.timeLabel.short}
</span>
</TooltipSimple>
)}
@@ -150,7 +162,7 @@ function PanelHeader({
<PanelActionsMenu
panelId={panelId}
panel={panel}
data={data}
data={query?.data}
panelActions={panelActions}
/>
)}

View File

@@ -14,9 +14,8 @@ import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
interface QueryPanelProps {
interface QueryPanelContentProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The kind's definition, narrowed to the query arm by `Panel`'s fork. */
@@ -28,17 +27,17 @@ interface QueryPanelProps {
}
/**
* A query-backed dashboard panel (header + body). Thin orchestrator: fetching
* lives in `usePanelQuery`, interactions in `usePanelInteractions`, state in
* `PanelBody`.
* The query arm's content (header + body) its own component so these hooks
* never mount for a static kind. Thin orchestrator: fetching lives in
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
*/
function QueryPanel({
function QueryPanelContent({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: QueryPanelProps): JSX.Element {
}: QueryPanelContentProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
@@ -67,14 +66,9 @@ function QueryPanel({
const drilldown = useDrilldown(panel, panelId);
return (
<div
className={styles.panel}
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}
>
<>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={data}
@@ -105,8 +99,8 @@ function QueryPanel({
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
</>
);
}
export default QueryPanel;
export default QueryPanelContent;

View File

@@ -1,56 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { PanelActionsConfig } from './Panel';
import PanelHeader from './PanelHeader/PanelHeader';
import StaticPanelBody from './StaticPanelBody/StaticPanelBody';
import styles from './Panel.module.scss';
interface StaticPanelProps {
panel: DashboardtypesPanelDTO;
panelId: string;
panelDefinition: RenderableStaticPanelDefinition;
isVisible?: boolean;
panelActions?: PanelActionsConfig;
}
/**
* A dashboard panel that renders from its own plugin spec: chrome plus the static
* body. No fetch, no status indicators, no time preference, no drilldown — none
* of that exists without a query.
*/
function StaticPanel({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: StaticPanelProps): JSX.Element {
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
// Stable locator, as on QueryPanel — actions that capture the panel node
// (and tests) address it the same way for both arms.
data-panel-root={panelId}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
timeLabel={null}
panelActions={panelActions}
/>
<StaticPanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
/>
</div>
);
}
export default StaticPanel;

View File

@@ -77,7 +77,6 @@ function QueryViewModalBody({
pagination,
} = query;
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
const drilldown = useDrilldown(draft, panelId, {
@@ -125,7 +124,7 @@ function QueryViewModalBody({
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
@@ -136,6 +135,7 @@ function QueryViewModalBody({
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
mode="query"
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}

View File

@@ -1,19 +1,15 @@
import { useCallback } from 'react';
import { PenLine } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import { PanelMode } from 'lib/visualization/panels/types';
import logEvent from 'api/common/logEvent';
import PanelTypeSwitcher from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/PanelTypeSwitcher';
import StaticPreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/StaticPreviewPane/StaticPreviewPane';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { DashboardEvents } from 'pages/DashboardPage/constants/events';
import { EQueryType } from 'types/common/dashboard';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import styles from './ViewPanelModal.module.scss';
interface StaticViewModalBodyProps {
@@ -24,10 +20,10 @@ interface StaticViewModalBodyProps {
}
/**
* The static-kind View modal body: the panel rendered live over the kind's
* editor pane the same layout idea as the query body, with the time window,
* query builder and drilldown machinery absent because none of it applies.
* Edits are temporary; "Edit panel" hands them to the full editor.
* The static-kind View modal body: the query body's layout with the kind's
* editor pane in the query-builder slot and the live panel below — the time
* window, query builder and drilldown machinery absent because none of it
* applies. Edits are temporary; "Switch to Edit Mode" hands them to the editor.
*/
function StaticViewModalBody({
panelId,
@@ -49,43 +45,23 @@ function StaticViewModalBody({
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<div className={styles.staticToolbar}>
<PanelTypeSwitcher
panelKind={draft.spec.plugin.kind}
queryType={EQueryType.QUERY_BUILDER}
onChange={onChangePanelKind}
/>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
prefix={<PenLine size={14} />}
onClick={onSwitchToEdit}
data-testid="static-view-switch-to-edit"
>
Edit panel
</Button>
<ViewPanelModalHeader
mode="static"
panelKind={draft.spec.plugin.kind}
onChangePanelKind={onChangePanelKind}
onSwitchToEdit={onSwitchToEdit}
/>
<div className={cx(styles.queryBuilder, styles.staticEditor)}>
<EditorPane spec={spec} onChangeSpec={setSpec} />
</div>
<div className={styles.staticPreview}>
<PanelHeader
<div className={styles.body}>
<StaticPreviewPane
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
panelDefinition={panelDefinition}
panel={draft}
panelId={panelId}
panelMode={PanelMode.STANDALONE_VIEW}
/>
</div>
<div className={styles.staticEditorPane}>
<EditorPane spec={spec} onChangeSpec={setSpec} />
</div>
</div>
);
}

View File

@@ -61,28 +61,8 @@
width: 240px;
}
// Static-kind modal body: toolbar, live panel, editor pane.
.staticToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.staticPreview {
display: flex;
flex-direction: column;
flex: 1 1 55%;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}
.staticEditorPane {
flex: 1 1 45%;
min-height: 0;
margin-top: 12px;
// The markdown editor fills its container (CodeMirror), unlike the
// content-sized query builder — give the static editor slot a fixed share.
.staticEditor {
flex: 0 0 30vh;
}

View File

@@ -10,7 +10,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
@@ -61,7 +61,7 @@ function ViewPanelModalContent({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
urlGraphType ?? toLegacyPanelType(baseSpec.plugin.kind),
}),
}
: { ...panel, spec: baseSpec };
@@ -76,7 +76,7 @@ function ViewPanelModalContent({
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[draftKind],
panelType: toLegacyPanelType(draftKind),
setSpec: draftApi.setSpec,
});

View File

@@ -10,12 +10,20 @@ import type {
import { usePanelTypeSelectItems } from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
import ConfigSelect from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { EQueryType } from 'types/common/dashboard';
import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
interface ViewPanelModalHeaderProps {
interface ViewPanelModalHeaderBaseProps {
onSwitchToEdit: () => void;
/** Draft's current kind (selected value of the panel-type selector). */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
}
interface QueryViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
mode: 'query';
selectedInterval: Time | CustomTimeType;
/** Current window bounds (epoch ms) — seed the picker's modal display. */
startMs: number;
@@ -27,9 +35,6 @@ interface ViewPanelModalHeaderProps {
/** Any query in flight — spins the refresh icon and disables it. */
isFetching: boolean;
onRefresh: () => void;
onSwitchToEdit: () => void;
/** Draft's current kind (selected value of the panel-type selector). */
panelKind: PanelKind;
/**
* The active query-builder tab (Query Builder / PromQL / ClickHouse). The type
* selector greys out kinds that can't be authored in it — e.g. List is
@@ -38,33 +43,34 @@ interface ViewPanelModalHeaderProps {
queryType: EQueryType;
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
signal: TelemetrytypesSignalDTO;
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the saved query + kind (drilldown reset). */
onResetQuery: () => void;
}
interface StaticViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
mode: 'static';
}
type ViewPanelModalHeaderProps =
| QueryViewModalHeaderProps
| StaticViewModalHeaderProps;
/**
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
* visualization kind, pick a per-view time window (isolated from the dashboard), and
* refresh. Mirrors V1's FullView header controls.
* refresh. Mirrors V1's FullView header controls. In static mode only the kind
* selector and the edit switch remain — the rest is query machinery.
*/
function ViewPanelModalHeader({
selectedInterval,
startMs,
endMs,
onTimeChange,
isFetching,
onRefresh,
onSwitchToEdit,
panelKind,
queryType,
signal,
onChangePanelKind,
onResetQuery,
}: ViewPanelModalHeaderProps): JSX.Element {
function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
const { onSwitchToEdit, panelKind, onChangePanelKind } = props;
const query = props.mode === 'query' ? props : null;
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
const panelTypeItems = usePanelTypeSelectItems({
queryType: query?.queryType ?? EQueryType.QUERY_BUILDER,
signal: query?.signal,
});
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
@@ -91,38 +97,42 @@ function ViewPanelModalHeader({
Switch to Edit Mode
</Button>
)}
<Button
variant="link"
color="primary"
onClick={onResetQuery}
data-testid="view-panel-reset-query"
>
Reset Query
</Button>
<div className={styles.toolbarTime}>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
isModalTimeSelection
disableUrlSync
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval as Time}
modalInitialStartTime={startMs}
modalInitialEndTime={endMs}
/>
{query && (
<Button
size="icon"
variant="outlined"
color="secondary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"
data-testid="view-panel-refresh"
variant="link"
color="primary"
onClick={query.onResetQuery}
data-testid="view-panel-reset-query"
>
<RotateCw className={cx({ 'animate-spin': isFetching })} />
Reset Query
</Button>
</div>
)}
{query && (
<div className={styles.toolbarTime}>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
isModalTimeSelection
disableUrlSync
onTimeChange={query.onTimeChange}
modalSelectedInterval={query.selectedInterval as Time}
modalInitialStartTime={query.startMs}
modalInitialEndTime={query.endMs}
/>
<Button
size="icon"
variant="outlined"
color="secondary"
onClick={query.onRefresh}
disabled={query.isFetching}
aria-label="Refresh"
data-testid="view-panel-refresh"
>
<RotateCw className={cx({ 'animate-spin': query.isFetching })} />
</Button>
</div>
)}
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { usePanelEditSession } from 'pages/DashboardPage/DashboardContainer/Pane
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import type { OpenDrilldownView } from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
@@ -89,7 +89,7 @@ export function useViewPanelMode({
() =>
fromPerses(
panel.spec.queries,
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
toLegacyPanelType(panel.spec.plugin.kind),
),
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
[],

View File

@@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import Panel from '../Panel';
@@ -26,6 +25,10 @@ jest.mock('../PanelBody/PanelBody', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="query-panel-body" />,
}));
jest.mock('../PanelActionsMenu/PanelActionsMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="panel-actions-menu" />,
}));
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
onPanelClick: jest.fn(),
@@ -74,7 +77,7 @@ describe('Panel — authoring-mode fork', () => {
beforeEach(() => {
mockUsePanelQuery.mockReset();
mockUsePanelQuery.mockReturnValue({
data: EMPTY_PANEL_QUERY_DATA,
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isFetching: false,
isPreviousData: false,
error: null,
@@ -101,6 +104,27 @@ describe('Panel — authoring-mode fork', () => {
expect(mockUsePanelQuery).not.toHaveBeenCalled();
});
it('swaps a hidden header for the floating drag pill + actions menu', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
const hiddenHeaderPanel = {
...panel,
spec: {
...panel.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { headerOptions: { hide: true } },
},
},
} as unknown as DashboardtypesPanelDTO;
render(<Panel panel={hiddenHeaderPanel} panelId="p1" />);
expect(screen.queryByTestId('panel-header')).not.toBeInTheDocument();
const dragHandle = screen.getByTestId('hidden-header-drag-handle');
expect(dragHandle).toHaveClass('panel-drag-handle');
expect(screen.getByTestId('panel-actions-menu')).toBeInTheDocument();
});
it('renders the static body in dashboard-view mode with panel chrome', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);

View File

@@ -76,6 +76,7 @@ function makePanelWithQueries(
}
const baseProps = {
mode: 'query' as const,
panel: makePanel(),
panelId: 'panel-1',
data: {
@@ -243,6 +244,22 @@ describe('PanelHeader actions menu', () => {
});
});
describe('PanelHeader static mode', () => {
it('renders the title and actions menu with no query-status chrome', () => {
renderWithProvider(
<PanelHeader mode="static" panelId="panel-1" panel={makePanel()} />,
);
expect(screen.getByText('My panel')).toBeInTheDocument();
expect(screen.getByTestId('panel-actions-menu')).toBeInTheDocument();
expect(screen.queryByTestId('panel-refetching')).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-header-search-trigger'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-time-preference')).not.toBeInTheDocument();
});
});
describe('PanelHeader time-preference pill', () => {
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
renderWithProvider(

View File

@@ -223,7 +223,9 @@ describe('ViewPanelModal', () => {
EditorPane: (): JSX.Element => <div data-testid="static-editor-pane" />,
};
(getPanelDefinition as jest.Mock).mockImplementation((kind: string) =>
kind === 'signoz/TimeSeriesPanel' ? staticDefinition : actual.getPanelDefinition(kind),
kind === 'signoz/TimeSeriesPanel'
? staticDefinition
: actual.getPanelDefinition(kind),
);
renderWithProvider(
@@ -235,6 +237,7 @@ describe('ViewPanelModal', () => {
/>,
);
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
expect(screen.getByTestId('static-editor-pane')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(

View File

@@ -74,6 +74,15 @@ const PANELS: Record<string, DashboardtypesPanelDTO> = {
B: makePanel('Panel B', "service = 'bravo'"),
};
const TEXT_PANEL = {
kind: 'Panel',
spec: {
display: { name: 'Notes' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hi' } },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const panelOf = (json: string): string => {
if (json.includes('alpha')) {
return 'A';
@@ -164,12 +173,23 @@ function Harness(): JSX.Element {
>
drilldown B
</button>
<button
type="button"
data-testid="open-text"
onClick={(): void => openView('T', TEXT_PANEL)}
>
open text
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<StagedQueryProbe />
<SearchProbe />
{expandedPanelId && <ModalBody panelId={expandedPanelId} />}
{/* The production shell forks on the kind's mode; the static arm never
mounts the query session this body mirrors. */}
{expandedPanelId && expandedPanelId !== 'T' && (
<ModalBody panelId={expandedPanelId} />
)}
</>
);
}
@@ -226,6 +246,20 @@ describe('useViewPanel', () => {
expect(search.get(QueryParams.graphType)).toBeNull();
});
it('carries no query in the URL and stages nothing for a static kind', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-text'));
const search = new URLSearchParams(
screen.getByTestId('search').textContent ?? '',
);
expect(search.get(QueryParams.expandedWidgetId)).toBe('T');
expect(search.get(QueryParams.compositeQuery)).toBeNull();
expect(stagedIds.every((id) => id === undefined)).toBe(true);
});
// The edit session commits any staged query on mount, so a staged query left over
// from the previous panel would make this panel's preview fetch the old query.
it('never lets the previous panel query reach the new panel draft', async () => {

View File

@@ -3,7 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { cloneDeep } from 'lodash-es';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { v4 as uuid } from 'uuid';
import { useDashboardEventMeta } from '../../../hooks/useDashboardEventMeta';
@@ -82,7 +82,7 @@ export function useClonePanel({
await clone;
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'clone',
panelType: PANEL_KIND_TO_PANEL_TYPE[source.panel.spec.plugin.kind],
panelType: toLegacyPanelType(source.panel.spec.plugin.kind),
panelKind: source.panel.spec.plugin.kind,
panelId,
...eventMeta,

View File

@@ -8,7 +8,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import { useDashboardEventMeta } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardEventMeta';
import { buildQueryRangeRequest } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
@@ -51,7 +51,7 @@ export function useCreateAlertFromPanel(): (
const panelKind = panel.spec.plugin.kind;
// Alerts are a V1 surface: the query pivots through the V1 `Query` shape and the
// URL carries a legacy panel type, so this flow keeps translating.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = toLegacyPanelType(panelKind);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'createAlerts',

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -58,7 +58,7 @@ export function useDeletePanel({
// is the V2 identity.
...(removedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[removedKind],
panelType: toLegacyPanelType(removedKind),
panelKind: removedKind,
}
: {}),

View File

@@ -3,14 +3,14 @@ import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { getTableCsvRows } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
interface UseDownloadPanelCsvArgs {
panel: DashboardtypesPanelDTO;
data: PanelQueryData;
data?: PanelQueryData;
/**
* Whether the kind's definition declares CSV as a downloadable format
* (`actions.download.csv`). Only tables carry tabular data, so this is the
@@ -32,7 +32,7 @@ export function useDownloadPanelCsv({
const fileName = panel.spec.display.name;
return useCallback((): void => {
if (!canDownloadCsv) {
if (!canDownloadCsv || !data) {
return;
}
const rows = getTableCsvRows(panel as PanelOfKind<'signoz/TablePanel'>, data);
@@ -42,7 +42,7 @@ export function useDownloadPanelCsv({
downloadCsv(rows, fileName);
void logEvent(DashboardDetailEvents.PanelExported, {
format: 'csv',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
panelType: toLegacyPanelType(panel.spec.plugin.kind),
panelKind: panel.spec.plugin.kind,
});
}, [canDownloadCsv, fileName, panel, data]);

View File

@@ -14,7 +14,7 @@ import { useDownloadPanelImage } from './useDownloadPanelImage';
interface UseDownloadPanelMenuItemArgs {
panelId: string;
panel: DashboardtypesPanelDTO;
data: PanelQueryData;
data?: PanelQueryData;
actions: PanelActionCapabilities;
}

View File

@@ -13,7 +13,7 @@ import type {
DrilldownContext,
OpenDrilldownView,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { buildAggregateData } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
@@ -79,7 +79,7 @@ export function useDrilldown(
options?: UseDrilldownOptions,
): UseDrilldownResult {
const kind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
const panelType = toLegacyPanelType(kind);
const queries = panel.spec.queries;
// Drilldown only for builder-authored panels with a query to drill into (PromQL /

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -79,7 +79,7 @@ export function useMovePanelToSection({
// is the V2 identity.
...(movedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[movedKind],
panelType: toLegacyPanelType(movedKind),
panelKind: movedKind,
}
: {}),

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