Compare commits

..

12 Commits

Author SHA1 Message Date
Abhi Kumar
c1fe436798 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
2026-09-01 17:38:27 +05:30
Abhi Kumar
87c244d9fa 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
2026-09-01 17:38:27 +05:30
Abhi Kumar
9f99aff850 chore(dashboards): regenerate the api contract for text header options
Assisted-by: Claude Fable 5
2026-09-01 17:38:27 +05:30
Naman Verma
2324530281 feat: add header options to text panel 2026-09-01 17:38:26 +05:30
Abhi Kumar
621b790734 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
2026-09-01 17:37:29 +05:30
Abhi Kumar
38b5c9137f 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.
2026-09-01 17:37:29 +05:30
Abhi Kumar
fe3a5b063c 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
2026-09-01 17:37:29 +05:30
Abhi Kumar
6e13cc8bed refactor(dashboards): fork the View modal on authoring mode
Phase 3b of the Text panel plan — the View modal gets the same shell the
editor got: ViewPanelModalContent now owns the draft and the kind-switch cache
(so a switch across authoring modes survives the branch swap) plus the
mount-only URL/handoff seeding, guarded so a composite query can only seed a
kind that takes one. The query body is today's content unchanged
(QueryViewModalBody); the static body renders the panel live over the kind's
editor pane, with a kind switcher and an "Edit panel" handoff that carries
in-modal edits — no time window, no query session, no drilldown.

useViewPanelMode drops its seeding and switch concerns and takes the hoisted
draft, which is what keeps its query machinery unmounted for static kinds.
Opening a static panel writes only the expanded-panel id: with no query to
stage or persist, nothing reaches the URL or the shared builder.

Assisted-by: Claude Fable 5
2026-09-01 17:37:29 +05:30
Abhi Kumar
2c38998bfd refactor(dashboards): fork the panel editor on authoring mode
Phase 3a of the Text panel plan. PanelEditorContainer becomes a shell owning
exactly the state that must survive a switch between authoring modes — the
draft and the kind-switch cache — and forks on the draft kind's `mode`:

- QueryEditorBody is today's editor body unchanged, now fed the hoisted draft
  and the narrowed definition. The lower pane comes from the definition's
  EditorPane: a shared QueryBuilderEditorPane for six kinds, and a List wrapper
  that absorbs the columns-editor footer both the editor and the View modal
  previously special-cased inline.
- StaticEditorBody is the query-less body: the kind's editor pane under a live
  preview of the draft (the same StaticPanelBody the grid renders), saving with
  `queries: []` — the only shape the API accepts. No builder seeding, no staged
  run, no compositeQuery URL writes; opening the editor on a static kind stamps
  no default query into the URL either.
- usePanelTypeSwitch handles static targets: first visit gets a fresh spec with
  queries emptied and the query builder left untouched; the per-kind cache makes
  the round trip restore both sides.

PanelEditorQueryBuilder now reads the narrowed definition it is handed instead
of looking capabilities up by kind, which also breaks the would-be import cycle
definition → pane → capabilities → registry → definition.

Still unreachable: no static kind is registered until the registration phase.

Assisted-by: Claude Fable 5
2026-09-01 17:37:29 +05:30
Abhi Kumar
9fbc09d61e feat(dashboards): render static panel kinds on the grid and public view
Phase 2 of the Text panel plan. StaticPanel (grid) and StaticPublicPanel mount
a static kind's renderer behind the shared panel chrome — no fetch, no status
indicators, no time preference, no drilldown, because none of that exists
without a query. StaticPanelBody is shared by both hosts (and later the editor
preview) and resolves `dashboardId` from the edit-context store, so previews of
unsaved panels read variables the same way the grid does.

Still unreachable: no static kind is registered, so both arms are exercised by
fork tests with the registry mocked — asserting the query hook never mounts.
2026-09-01 17:37:29 +05:30
Abhi Kumar
7b9ca2a273 refactor(dashboards): split PanelDefinition into query and static arms
Phase 1 of the Text panel plan (frontend/docs/text-panel-implementation-plan.md).

A definition is now one of two shapes discriminated by a root `mode`: query
kinds declare their whole query surface (renderer, signals, query types,
builder fields, request capabilities); static kinds — none exist yet — declare
a renderer that takes no query data and an editor pane that replaces the query
builder. No dummy capabilities, no empty declarations standing in for "not
applicable".

Hosts fork on `mode` and pass the narrowed definition down: Panel and
PublicPanel become hookless forks over extracted QueryPanel/QueryPublicPanel
bodies, PanelBody takes the query arm's Renderer directly, and readers that
can't take the definition as a prop yet assert the arm via
requireQueryPanelDefinition. Leaf query hooks keep non-null contracts.

No behavior change: generated types are untouched, so the kind universe is
still the seven query kinds and the static arm of every fork is unreachable.
2026-09-01 17:37:29 +05:30
Naman Verma
04d83dc656 feat: add spec for text panel 2026-09-01 17:37:29 +05:30
114 changed files with 3832 additions and 870 deletions

View File

@@ -2984,6 +2984,11 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3321,6 +3326,11 @@ components:
- kind
- spec
type: object
DashboardtypesPanelBackground:
enum:
- solid
- transparent
type: string
DashboardtypesPanelFormatting:
properties:
decimalPrecision:
@@ -3341,6 +3351,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:
@@ -3351,6 +3362,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3361,6 +3373,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3434,6 +3447,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:
@@ -3725,6 +3750,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:
@@ -3934,6 +3989,12 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:

View File

@@ -4277,6 +4277,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
@@ -4284,7 +4336,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -5206,6 +5259,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
}
/**
* @nullable

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

@@ -5,6 +5,7 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -67,7 +68,7 @@ function ConfigPane({
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.

View File

@@ -34,6 +34,7 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']

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

@@ -8,6 +8,7 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),

View File

@@ -21,20 +21,15 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toLegacyPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
@@ -55,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelKind,
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
@@ -65,10 +60,10 @@ 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[panelKind];
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 = panelKind === 'signoz/ListPanel';
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -99,12 +94,12 @@ function PanelEditorQueryBuilder({
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
[panelDefinition.queryBuilderFields, signal],
);
const items = useMemo(() => {
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const { supportedQueryTypes } = panelDefinition;
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -151,7 +146,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
return (
<div

View File

@@ -0,0 +1,28 @@
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
/**
* The default query-kind editor pane: the query-builder tabs with no extras. A
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
*/
function QueryBuilderEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
/>
);
}
export default QueryBuilderEditorPane;

View File

@@ -4,6 +4,9 @@ import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
@@ -48,7 +51,7 @@ function renderBuilder(
): void {
render(
<PanelEditorQueryBuilder
panelKind={panelKind as never}
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}

View File

@@ -6,7 +6,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -20,8 +20,8 @@ import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
panelDefinition: RenderableQueryPanelDefinition;
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
@@ -99,6 +99,7 @@ function PreviewPane({
})}
>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={data}
@@ -111,7 +112,7 @@ function PreviewPane({
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={data}

View File

@@ -0,0 +1,372 @@
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
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 { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface QueryEditorBodyProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** The draft kind's definition, narrowed by the shell's fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind editor body: a resizable split with the live preview + the
* kind's editor pane on the left and the config pane on the right. Draft and
* kind-switch state live in the shell; this body owns the query session and the
* save round-trip.
*/
function QueryEditorBody({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
draftApi,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
const { EditorPane } = panelDefinition;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: toLegacyPanelType(panelKind),
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<EditorPane
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}
onChangeSpec={setSpec}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default QueryEditorBody;

View File

@@ -0,0 +1,162 @@
import { useCallback } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import StaticPreviewPane from './StaticPreviewPane/StaticPreviewPane';
import layoutStorage from './layoutStorage';
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;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* Editor body for a kind that renders from its own plugin spec: the kind's
* editor pane under a live preview of the draft, the config pane on the right.
* No query session, no builder seeding, no staged-run — the preview re-renders
* from the draft spec on every edit.
*/
function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticEditorBodyProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = draftApi;
const { EditorPane } = panelDefinition;
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// `queries: []` is the only shape the API accepts for a static kind.
const savedPanelId = await save({ ...draft.spec, queries: [] });
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isSpecDirty}
isSaving={isSaving}
showSwitchToView={false}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onClose={onCloseEditor}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="40%" maxSize="65%" defaultSize="55%">
<StaticPreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="60%" defaultSize="45%">
<EditorPane spec={spec} onChangeSpec={setSpec} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default StaticEditorBody;

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

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
@@ -232,7 +233,12 @@ describe('PanelEditorContainer composition', () => {
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
expect.objectContaining({
panelDefinition: expect.objectContaining({
kind: 'signoz/TimeSeriesPanel',
mode: 'query',
}),
}),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -256,7 +262,7 @@ describe('PanelEditorContainer composition', () => {
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(

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

@@ -18,6 +18,8 @@ import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
@@ -83,7 +85,7 @@ function EditorRoute(): JSX.Element {
return (
<PanelEditorQueryBuilder
panelKind="signoz/TimeSeriesPanel"
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}

View File

@@ -19,6 +19,10 @@ jest.mock('lib/query/panelQuery', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isQuerylessPanelKind: jest.requireActual('../../../Panels/capabilities')
.isQuerylessPanelKind,
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),

View File

@@ -4,24 +4,19 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
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 { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
@@ -38,6 +33,12 @@ interface UsePanelEditSessionArgs {
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
/**
* Externally-owned draft. The editor shell hoists it above its mode fork so a
* kind switch across modes survives the branch swap; hosts without a fork (the
* View modal, until it forks) omit it and the session owns the draft.
*/
draftApi?: PanelEditorDraftApi;
}
export interface UsePanelEditSessionReturn {
@@ -50,7 +51,7 @@ export interface UsePanelEditSessionReturn {
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderablePanelDefinition;
panelDefinition: RenderableQueryPanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
@@ -62,8 +63,6 @@ export interface UsePanelEditSessionReturn {
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -80,15 +79,18 @@ export function usePanelEditSession({
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
draftApi,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
// Called unconditionally (hooks rules); unused when a hoisted draft is passed in.
const internalDraftApi = usePanelEditorDraft(panel, savedPanel);
const { draft, spec, setSpec, isSpecDirty, reset } =
draftApi ?? internalDraftApi;
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// 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 = toLegacyPanelType(panelKind);
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({
@@ -109,12 +111,6 @@ export function usePanelEditSession({
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
@@ -128,6 +124,5 @@ export function usePanelEditSession({
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -18,9 +18,12 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
isQuerylessPanelKind,
resolveQueryType,
} from '../../Panels/capabilities';
import {
toLegacyPanelType,
type PanelKind,
} from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
@@ -110,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
@@ -128,11 +131,25 @@ export function usePanelTypeSwitch({
queries,
});
// Revisit → restore the stash verbatim (the reversibility path).
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
redirectWithQueryBuilderData(cached.builderQuery);
if (!isQuerylessPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isQuerylessPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
return;
}

View File

@@ -1,56 +1,13 @@
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { toLegacyPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
export interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -74,293 +31,42 @@ interface PanelEditorContainerProps {
}
/**
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
* V2 panel editor page shell. Owns exactly the state that must survive a switch
* between authoring modes — the draft and the kind-switch cache — and forks on
* the draft kind's `mode`: query kinds get the session-backed body, static kinds
* an editor pane over a live preview with no query machinery at all.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { panel, savedPanel } = props;
const draftApi = usePanelEditorDraft(panel, savedPanel);
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toLegacyPanelType(panelKind),
setSpec: draftApi.setSpec,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
if (panelDefinition.mode === 'static') {
return (
<StaticEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
}
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<QueryEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
}

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

@@ -11,6 +11,8 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
@@ -32,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[]> = {
@@ -43,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,
@@ -107,7 +116,7 @@ const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
@@ -149,7 +158,8 @@ describe('panel capabilities guard', () => {
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const { queryCapabilities } = getPanelDefinition(unknownKind);
const queryCapabilities = requireQueryPanelDefinition(unknownKind)
.queryCapabilities;
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);

View File

@@ -2,7 +2,11 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import {
mergeQueryBuilderFieldRule,
type FilterConfigsPartial,
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
/**
@@ -13,11 +17,46 @@ import type { PanelKind } from './types/panelKind';
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Renders from its own plugin spec — no query surface at all. */
export function isQuerylessPanelKind(kind: PanelKind): boolean {
return getPanelDefinition(kind).mode === 'static';
}
/**
* The kind's definition narrowed to the query arm, or null for a static kind.
* The null is what hosts fork on; the accessors below fold it into "supports
* nothing" for the guard questions.
*/
export function getQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition | null {
const definition = getPanelDefinition(kind);
return definition.mode === 'query' ? definition : null;
}
/**
* The query arm, asserted present. For call sites that a host mounts only after
* narrowing `mode === 'query'` but that read the definition by kind rather than
* receiving it as a prop — the throw makes that invariant executable instead of
* silently null-tolerant.
*/
export function requireQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition {
const definition = getQueryPanelDefinition(kind);
if (!definition) {
throw new Error(
`query machinery mounted for query-less panel kind ${kind} — the host must fork on definition.mode before this point`,
);
}
return definition;
}
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getPanelDefinition(kind).supportedSignals;
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
}
export function isSignalSupported(
@@ -29,7 +68,7 @@ export function isSignalSupported(
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getPanelDefinition(kind).supportedQueryTypes;
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
}
export function isQueryTypeSupportedByPanelKind(
@@ -53,6 +92,10 @@ export function isPanelCombinationValid({
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isQuerylessPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return false;
}
@@ -73,7 +116,11 @@ export function resolveQueryType(
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
return supported.includes(preferred) ? preferred : supported[0];
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
}
/**
@@ -85,7 +132,6 @@ export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getPanelDefinition(kind).queryBuilderFields;
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
return mergeQueryBuilderFieldRule(rule, signal);
}

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

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -10,7 +11,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -10,7 +11,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -0,0 +1,34 @@
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
import ListColumnsEditor from '../../../PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
/**
* List's editor pane: the query builder with the columns editor pinned below it.
* Declared here so no editor host carries a List special case.
*/
function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
spec,
onChangeSpec,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
footer={
<ListColumnsEditor spec={spec} onChangeSpec={onChangeSpec} signal={signal} />
}
/>
);
}
export default ListEditorPane;

View File

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -11,7 +12,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
mode: 'query',
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,

View File

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -10,7 +11,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -10,7 +11,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -10,7 +11,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

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,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,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,23 @@
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',
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,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

@@ -1,4 +1,5 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -10,7 +11,9 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -4,6 +4,7 @@ import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
/**
@@ -18,7 +19,9 @@ import Renderer from './Renderer';
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
mode: 'query',
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],

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 {
PanelRegistry,
@@ -21,6 +22,7 @@ export const PANELS: PanelRegistry = {
[PieChart.kind]: PieChart,
[Table.kind]: Table,
[List.kind]: List,
[Text.kind]: Text,
};
/**

View File

@@ -22,6 +22,15 @@ export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
export function mergeQueryBuilderFieldRule(
rule: QueryBuilderFieldRule,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code

View File

@@ -1,5 +1,8 @@
import type { ComponentType } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
@@ -9,7 +12,11 @@ import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
import type {
BaseRendererProps,
PanelRendererProps,
StaticRendererProps,
} from './rendererProps';
/** Export formats offered under the single "Download" action. */
export enum DownloadFormat {
@@ -60,11 +67,48 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
drilldown: false,
};
export interface PanelDefinition<K extends PanelKind = PanelKind> {
export interface PanelDefinitionBase<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
actions: PanelActionCapabilities;
}
/** Props for a static kind's authoring pane — rendered where the query builder sits. */
export interface StaticEditorPaneProps {
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* Props for a query kind's authoring pane (the editor's lower-left slot). Spec
* read/write is included so a kind's pane can edit its own spec slices (the List
* columns editor) without the host carrying per-kind conditionals.
*/
export interface QueryEditorPaneProps {
/** The kind's definition, narrowed by the host's fork. */
panelDefinition: RenderableQueryPanelDefinition;
signal: TelemetrytypesSignalDTO;
isLoadingQueries: boolean;
onStageRunQuery: () => void;
onCancelQuery: () => void;
/** Pin the tabs row to the pane top; the View modal opts out. */
stickyHeader?: boolean;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* A kind that renders from a query. Declares its whole query surface here, so a
* kind without one carries no query declarations at all — no dummy capabilities,
* no empty signal lists standing in for "not applicable".
*/
export interface QueryPanelDefinition<K extends PanelKind = PanelKind>
extends PanelDefinitionBase<K> {
mode: 'query';
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
@@ -73,16 +117,38 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}
/**
* A kind that renders from its own plugin spec and saves with `queries: []` (the
* API rejects anything else). Its renderer takes no query data, and its editor
* pane replaces the query builder (TDD D8). No query machinery mounts for it
* anywhere — every host forks on `mode` before touching a query hook.
*/
export interface StaticPanelDefinition<K extends PanelKind = PanelKind>
extends PanelDefinitionBase<K> {
mode: 'static';
Renderer: ComponentType<StaticRendererProps<K>>;
EditorPane: ComponentType<StaticEditorPaneProps>;
}
export type PanelDefinition<K extends PanelKind = PanelKind> =
| QueryPanelDefinition<K>
| StaticPanelDefinition<K>;
// Every kind must be registered, so getPanelDefinition never returns undefined.
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
// The arms with their Renderer widened to the kind-agnostic prop surface. Declared
// explicitly rather than via `Omit` over the union, which collapses to common keys.
export interface RenderableQueryPanelDefinition extends Omit<
QueryPanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}
export type RenderableStaticPanelDefinition = StaticPanelDefinition<PanelKind>;
export type RenderablePanelDefinition =
| RenderableQueryPanelDefinition
| RenderableStaticPanelDefinition;

View File

@@ -8,7 +8,12 @@ import type { DashboardtypesPanelPluginKindDTO } from 'api/generated/services/si
*/
export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
/**
* Partial: a static kind has no legacy `PANEL_TYPES` counterpart — V1 never had
* one. V1-era query/alert/drilldown paths read {@link toLegacyPanelType}; new code
* reads this map directly, where the `undefined` is worth seeing.
*/
export const PANEL_KIND_TO_PANEL_TYPE: Partial<Record<PanelKind, PANEL_TYPES>> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
@@ -18,6 +23,17 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/ListPanel': PANEL_TYPES.LIST,
};
/**
* The legacy `PANEL_TYPES` a kind maps to, for the V1-era query, alert and
* drilldown surfaces that still speak it. Every such path is gated on the kind's
* query arm or an action capability a static kind declares `false`, so the
* fallback is unreachable — it exists to keep those call sites total rather than
* have each invent its own.
*/
export function toLegacyPanelType(kind: PanelKind): PANEL_TYPES {
return PANEL_KIND_TO_PANEL_TYPE[kind] ?? PANEL_TYPES.TIME_SERIES;
}
/**
* 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

View File

@@ -75,6 +75,19 @@ export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
};
};
/**
* Props a static (query-less) renderer receives: its panel and render context,
* nothing of the fetch lifecycle. `dashboardId` scopes store reads (resolved
* variables); the editor route seeds the same store, so previews of an unsaved
* panel resolve variables the way the grid does.
*/
export interface StaticRendererProps<K extends PanelKind = PanelKind> {
panelId: string;
panel: PanelOfKind<K>;
panelMode: PanelMode;
dashboardId?: string;
}
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
// and only the gestures it supports. The default K = PanelKind is the widest surface.

View File

@@ -3,12 +3,14 @@ import type {
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesHeaderOptionsDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTableFormattingDTO,
DashboardtypesTableThresholdDTO,
DashboardtypesTextPresentationDTO,
DashboardtypesThresholdWithLabelDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
@@ -20,10 +22,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
@@ -50,6 +54,8 @@ export enum SectionKind {
Thresholds = 'thresholds',
ContextLinks = 'contextLinks',
Columns = 'columns',
TextLayout = 'presentation',
PanelHeader = 'headerOptions',
}
/**
@@ -92,6 +98,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)
}
/**
@@ -134,7 +142,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 = (
@@ -167,6 +179,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

@@ -2,8 +2,8 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getPanelDefinition } from '../registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { getSupportedSignals } from '../capabilities';
import { toLegacyPanelType } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
/**
@@ -13,11 +13,11 @@ import { fromPerses } from '../../queryV5/persesQueryAdapters';
*/
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
const kind = panel.spec.plugin.kind;
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
const [defaultSignal] = getSupportedSignals(kind);
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
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,21 +1,14 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
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 { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
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';
/**
@@ -37,8 +30,9 @@ interface PanelProps {
}
/**
* A single dashboard panel (header + body). Thin orchestrator: fetching lives in
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
* 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,
@@ -46,73 +40,57 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
return (
<div
className={styles.panel}
className={cx(styles.panel, {
[styles.transparent]: isTransparentPanel(panel.spec),
})}
data-panel-visible={isVisible ? 'true' : 'false'}
// 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
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
{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

@@ -1,11 +1,14 @@
import type { ComponentType } from 'react';
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import PanelLoader from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
import PanelMessage from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type {
BaseRendererProps,
DashboardPreference,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
import { getResponseType } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import type {
@@ -17,8 +20,8 @@ import { panelStatusFromError } from '../PanelStatus/utils';
import styles from './PanelBody.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
panelDefinition: RenderablePanelDefinition;
/** The query arm's renderer — hosts narrow `mode === 'query'` before this mounts. */
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
panel: DashboardtypesPanelDTO;
panelId: string;
data: PanelQueryData;
@@ -51,7 +54,7 @@ interface PanelBodyProps {
* first-load / renderer. The renderer keeps stale data mounted across refetches.
*/
function PanelBody({
panelDefinition,
Renderer,
panel,
panelId,
data,
@@ -116,7 +119,7 @@ function PanelBody({
return (
<div className={styles.panelContainer}>
<panelDefinition.Renderer
<Renderer
panelId={panelId}
panel={panel}
data={data}

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import PanelBody from '../PanelBody';
@@ -8,10 +7,6 @@ import PanelBody from '../PanelBody';
// Stub the renderer so these tests focus on PanelBody's state machine.
const MockRenderer = (): JSX.Element => <div data-testid="mock-renderer" />;
const panelDefinition = {
Renderer: MockRenderer,
} as unknown as RenderablePanelDefinition;
function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
@@ -24,7 +19,7 @@ function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
}
const baseProps = {
panelDefinition,
Renderer: MockRenderer,
panelId: 'p1',
data: {} as PanelQueryData,
isFetching: false,

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

@@ -5,6 +5,7 @@ import {
Hash,
List,
Table,
Type,
} from '@signozhq/icons';
import type { PanelType } from './types';
@@ -21,4 +22,5 @@ export const PANEL_TYPES: PanelType[] = [
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
{ panelKind: 'signoz/TextPanel', label: 'Text', Icon: Type },
];

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelActionsConfig } from './Panel';
import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
interface QueryPanelContentProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The kind's definition, narrowed to the query arm by `Panel`'s fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* 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 QueryPanelContent({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: QueryPanelContentProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</>
);
}
export default QueryPanelContent;

View File

@@ -0,0 +1,5 @@
.body {
height: 100%;
min-height: 0;
overflow: hidden;
}

View File

@@ -0,0 +1,46 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import styles from './StaticPanelBody.module.scss';
interface StaticPanelBodyProps {
panelDefinition: RenderableStaticPanelDefinition;
panel: DashboardtypesPanelDTO;
panelId: string;
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
panelMode?: PanelMode;
}
/**
* Body for a kind that renders from its own plugin spec: the static renderer and
* nothing else — no fetch, no loading or error states. Shared by the dashboard
* grid, the public view and the editor preview, which is what keeps the preview
* live while the draft spec changes.
*/
function StaticPanelBody({
panelDefinition,
panel,
panelId,
panelMode = PanelMode.DASHBOARD_VIEW,
}: StaticPanelBodyProps): JSX.Element {
// From the edit context, not props: the editor route seeds it too, so an
// unsaved panel's preview resolves variables the same way the grid does.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { Renderer } = panelDefinition;
return (
<div className={styles.body} data-testid="static-panel-body">
<Renderer
panelId={panelId}
panel={panel as PanelOfKind}
panelMode={panelMode}
dashboardId={dashboardId || undefined}
/>
</div>
);
}
export default StaticPanelBody;

View File

@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { useDrilldown } from '../hooks/useDrilldown';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import {
DashboardDetailEvents,
DashboardEvents,
} from 'pages/DashboardPage/constants/events';
interface QueryViewModalBodyProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Close the modal — wired to the graph manager's Save/Cancel. */
onClose: () => void;
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind View modal body: a compact drilldown editor. It renders an
* editable draft of the panel (preview) over a per-view time window plus the
* kind's editor pane, so the user can tweak + Stage & Run without touching the
* dashboard. Edits are temporary.
*/
function QueryViewModalBody({
panel,
panelId,
onClose,
draftApi,
onChangePanelKind,
}: QueryViewModalBodyProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
query,
runQuery,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride, draftApi });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
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, {
openDrilldownView: applyDrilldownQuery,
});
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
const handleDragSelect = useCallback(
(start: number, end: number): void => {
if (Math.floor(start) !== Math.floor(end)) {
void logEvent(DashboardDetailEvents.PanelZoomed, {
context: 'viewModal',
panelType: draft.spec.plugin.kind,
panelId,
});
}
onDragSelect(start, end);
},
[onDragSelect, draft.spec.plugin.kind, panelId],
);
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
const { EditorPane } = panelDefinition;
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
mode="query"
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<EditorPane
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
spec={draft.spec}
onChangeSpec={setSpec}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={handleDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
hideHeader
/>
</div>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}
export default QueryViewModalBody;

View File

@@ -0,0 +1,69 @@
import { useCallback } from 'react';
import cx from 'classnames';
import { PanelMode } from 'lib/visualization/panels/types';
import logEvent from 'api/common/logEvent';
import StaticPreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/StaticPreviewPane/StaticPreviewPane';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { DashboardEvents } from 'pages/DashboardPage/constants/events';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import styles from './ViewPanelModal.module.scss';
interface StaticViewModalBodyProps {
panelId: string;
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* 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,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticViewModalBodyProps): JSX.Element {
const { draft, spec, setSpec } = draftApi;
const { EditorPane } = panelDefinition;
const openPanelEditor = useOpenPanelEditor();
const onSwitchToEdit = useCallback((): void => {
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, { panelId });
// Carry the in-modal edits so the editor opens on them, not the saved panel.
openPanelEditor(panelId, {
handoffState: { editSpec: { ...draft.spec, queries: [] } },
});
}, [openPanelEditor, panelId, draft.spec]);
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<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.body}>
<StaticPreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
panelMode={PanelMode.STANDALONE_VIEW}
/>
</div>
</div>
);
}
export default StaticViewModalBody;

View File

@@ -60,3 +60,9 @@
.panelTypeSelector {
width: 240px;
}
// 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

@@ -1,26 +1,22 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import ListColumnsEditor from 'pages/DashboardPage/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { useMemo } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
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 { 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';
import { useDrilldown } from '../hooks/useDrilldown';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import {
DashboardDetailEvents,
DashboardEvents,
} from 'pages/DashboardPage/constants/events';
import QueryViewModalBody from './QueryViewModalBody';
import StaticViewModalBody from './StaticViewModalBody';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
@@ -30,168 +26,79 @@ interface ViewPanelModalContentProps {
}
/**
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
* the panel (preview) over a per-view time window plus the shared query builder, so the
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
* View-modal shell. Owns the draft and the kind-switch cache — the state that
* must survive a switch between authoring modes — and forks on the draft kind's
* `mode`, so a static kind mounts no time window, query session or drilldown.
*/
function ViewPanelModalContent({
panel,
panelId,
onClose,
}: ViewPanelModalContentProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
}: ViewPanelModalContentProps): JSX.Element {
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the
// query still comes from the URL. Falls back to the saved panel for a plain "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() => {
// A URL query can only seed a kind that takes one.
const isQuerySeeded =
compositeQuery && getPanelDefinition(baseSpec.plugin.kind).mode === 'query';
return isQuerySeeded
? {
...panel,
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? toLegacyPanelType(baseSpec.plugin.kind),
}),
}
: { ...panel, spec: baseSpec };
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
const draftApi = usePanelEditorDraft(initialPanel);
const draftKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(draftKind);
// 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, {
openDrilldownView: applyDrilldownQuery,
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toLegacyPanelType(draftKind),
setSpec: draftApi.setSpec,
});
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
const handleDragSelect = useCallback(
(start: number, end: number): void => {
if (Math.floor(start) !== Math.floor(end)) {
void logEvent(DashboardDetailEvents.PanelZoomed, {
context: 'viewModal',
panelType: draft.spec.plugin.kind,
panelId,
});
}
onDragSelect(start, end);
},
[onDragSelect, draft.spec.plugin.kind, panelId],
);
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
if (panelDefinition.mode === 'static') {
return (
<StaticViewModalBody
panelId={panelId}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
}
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<PanelEditorQueryBuilder
panelKind={draft.spec.plugin.kind}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
footer={
isListPanel ? (
<ListColumnsEditor
spec={draft.spec}
onChangeSpec={setSpec}
signal={signal}
/>
) : undefined
}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={handleDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
hideHeader
/>
</div>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
<QueryViewModalBody
panel={panel}
panelId={panelId}
onClose={onClose}
draftApi={draftApi}
onChangePanelKind={onChangePanelKind}
/>
);
}

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

@@ -5,17 +5,12 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditSession } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import type { OpenDrilldownView } from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
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';
@@ -23,16 +18,15 @@ import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { EQueryType } from 'types/common/dashboard';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
time: PanelQueryTimeOverride;
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
}
export interface UseViewPanelModeReturn {
@@ -41,7 +35,7 @@ export interface UseViewPanelModeReturn {
/** Update the draft's spec in place (e.g. the List columns editor). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
panelDefinition: RenderablePanelDefinition;
panelDefinition: RenderableQueryPanelDefinition;
/**
* Builder datasource driving the query builder and the panel-type selector's
* disabled rule. Resolved from the query, falling back to the kind's default
@@ -54,8 +48,6 @@ export interface UseViewPanelModeReturn {
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
runQuery: () => void;
/** Switch the draft's visualization kind (temporary; reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the query the view opened with, discarding in-modal edits. */
resetQuery: () => void;
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
@@ -77,58 +69,27 @@ export function useViewPanelMode({
panel,
panelId,
time,
draftApi,
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() =>
compositeQuery
? {
...panel,
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: { ...panel, spec: baseSpec },
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const {
draft,
panelDefinition,
defaultSignal,
query,
runQuery,
onChangePanelKind,
buildSaveSpec,
reset,
setSpec,
} = usePanelEditSession({ panel: initialPanel, panelId, time });
} = usePanelEditSession({ panel, panelId, time, draftApi });
// The query the view opened with, captured once — the Reset target.
const savedQuery = useMemo(
() =>
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
[],
@@ -178,7 +139,6 @@ export function useViewPanelMode({
queryType: currentQuery.queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,

View File

@@ -0,0 +1,139 @@
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 Panel from '../Panel';
// Real registry by default; the static cases override per render.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
});
jest.mock('pages/DashboardPage/DashboardContainer/hooks/usePanelQuery', () => ({
usePanelQuery: jest.fn(),
}));
// Chrome + query-path collaborators stubbed: this file tests the mode fork, not them.
jest.mock('../PanelHeader/PanelHeader', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="panel-header" />,
}));
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(),
enableDrillDown: false,
contextMenuProps: {},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: undefined,
}),
}));
jest.mock('periscope/components/ContextMenu', () => ({
__esModule: true,
default: (): null => null,
}));
const mockUsePanelQuery = usePanelQuery as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const panel = {
kind: 'Panel',
spec: {
display: { name: 'P' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
function StaticRenderer(props: { panelMode: string }): JSX.Element {
return <div data-testid="fake-static-renderer" data-mode={props.panelMode} />;
}
const staticDefinition = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Static',
sections: [],
actions: { search: false },
mode: 'static',
Renderer: StaticRenderer,
EditorPane: StaticRenderer,
};
describe('Panel — authoring-mode fork', () => {
beforeEach(() => {
mockUsePanelQuery.mockReset();
mockUsePanelQuery.mockReturnValue({
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
pagination: undefined,
});
});
it('mounts the query body and fetch for a query kind', () => {
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('query-panel-body')).toBeInTheDocument();
expect(mockUsePanelQuery).toHaveBeenCalledTimes(1);
});
it('mounts the static renderer for a static kind with no query hook at all', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('static-panel-body')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('query-panel-body')).not.toBeInTheDocument();
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);
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('fake-static-renderer')).toHaveAttribute(
'data-mode',
'DASHBOARD_VIEW',
);
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
});
});

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

@@ -1,10 +1,11 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactElement } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
@@ -21,6 +22,30 @@ jest.mock(
);
// Isolate from the draft/query-builder plumbing (its own suite covers it).
// Real registry by default; the static-fork test overrides one kind.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
});
// The shell reads the URL seed + kind switch itself; stub its collaborators so
// the suite needs no router and keeps asserting through the mocked mode hook.
jest.mock('hooks/queryBuilder/useGetCompositeQueryParam', () => ({
useGetCompositeQueryParam: (): null => null,
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => new URLSearchParams(),
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch',
() => ({
usePanelTypeSwitch: (): unknown => ({ onChangePanelKind: jest.fn() }),
}),
);
jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
useViewPanelMode: (args: {
panel: { spec: { plugin: { kind: string } } };
@@ -30,8 +55,14 @@ jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
draft: args.panel,
panelDefinition: {
kind,
mode: 'query',
actions: { search: kind === 'signoz/ListPanel' },
Renderer: (): null => null,
// Same testid as the real pane: the suite asserts the modal fills its
// query-builder slot from the definition.
EditorPane: (): JSX.Element => (
<div data-testid="panel-editor-v2-query-builder" />
),
},
query: {
data: { response: undefined, requestPayload: undefined, legendMap: {} },
@@ -161,8 +192,7 @@ describe('ViewPanelModal', () => {
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
});
it('invokes onClose when the modal is dismissed', async () => {
const user = userEvent.setup();
it('invokes onClose when the modal is dismissed', () => {
const onClose = jest.fn();
renderWithProvider(
<ViewPanelModal
@@ -172,10 +202,54 @@ describe('ViewPanelModal', () => {
onClose={onClose}
/>,
);
await user.click(screen.getByLabelText('Close'));
// fireEvent: user-event's pointer walk races Radix's layer bookkeeping in
// jsdom, transiently dropping the dialog's pointer-events and failing the
// interaction check. The assertion is only that Close wires to onClose.
fireEvent.click(screen.getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
it('mounts the static body — editor pane, no query builder slot — for a static kind', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
const staticDefinition = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Static',
sections: [],
actions: {},
mode: 'static',
Renderer: (): JSX.Element => <div data-testid="fake-static-renderer" />,
EditorPane: (): JSX.Element => <div data-testid="static-editor-pane" />,
};
(getPanelDefinition as jest.Mock).mockImplementation((kind: string) =>
kind === 'signoz/TimeSeriesPanel'
? staticDefinition
: actual.getPanelDefinition(kind),
);
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open
onClose={jest.fn()}
/>,
);
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
expect(screen.getByTestId('static-editor-pane')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-query-builder'),
).not.toBeInTheDocument();
expect(mockPreviewPaneRender).not.toHaveBeenCalled();
(getPanelDefinition as jest.Mock).mockImplementation(
actual.getPanelDefinition,
);
});
// Charts share one global cursor-sync key and uPlot replays drag across the
// group; the modal must opt out so a drag here can't move the dashboard's time.
it('opts the chart out of the dashboard cursor-sync group', () => {

View File

@@ -108,6 +108,7 @@ jest.mock(
// importing the whole renderer registry into the test.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: (kind: string): unknown => ({
mode: 'query',
actions: { drilldown: kind !== 'signoz/ListPanel' },
}),
}));

View File

@@ -10,6 +10,8 @@ import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/perse
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
@@ -72,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';
@@ -89,10 +100,13 @@ const stagedIds: (string | undefined)[] = [];
function ModalBody({ panelId }: { panelId: string }): JSX.Element {
const { currentQuery } = useQueryBuilder();
// The modal shell owns the draft in production; mirrored here.
const draftApi = usePanelEditorDraft(PANELS[panelId]);
const { draft } = useViewPanelMode({
panel: PANELS[panelId],
panelId,
time: { startMs: 0, endMs: 1000 },
draftApi,
});
renders.push({
current: panelOf(JSON.stringify(currentQuery)),
@@ -159,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} />
)}
</>
);
}
@@ -221,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,14 +8,14 @@ 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';
import { envelopesToQuery } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -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',
@@ -70,7 +70,8 @@ export function useCreateAlertFromPanel(): (
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
queryCapabilities: getPanelDefinition(panelKind).queryCapabilities,
// Reached only through the menu item `actions.createAlert` gates.
queryCapabilities: requireQueryPanelDefinition(panelKind).queryCapabilities,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,

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,8 @@ 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';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
@@ -78,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 /
@@ -180,7 +181,7 @@ export function useDrilldown(
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelKind: kind,
queryCapabilities: getPanelDefinition(kind).queryCapabilities,
queryCapabilities: requireQueryPanelDefinition(kind).queryCapabilities,
v1Query,
enabled: showAggregateMenu,
});

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