mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-01 17:10:42 +01:00
Compare commits
12 Commits
feat/text-
...
feat/text-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1fe436798 | ||
|
|
87c244d9fa | ||
|
|
9f99aff850 | ||
|
|
2324530281 | ||
|
|
621b790734 | ||
|
|
38b5c9137f | ||
|
|
fe3a5b063c | ||
|
|
6e13cc8bed | ||
|
|
2c38998bfd | ||
|
|
9fbc09d61e | ||
|
|
7b9ca2a273 | ||
|
|
04d83dc656 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
text: 'Markdown body',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
|
||||
@@ -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}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
.body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useDownloadPanelImage } from './useDownloadPanelImage';
|
||||
interface UseDownloadPanelMenuItemArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
data?: PanelQueryData;
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user