Compare commits

..

20 Commits

Author SHA1 Message Date
Abhi Kumar
119f59adce chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-01 17:15:42 +05:30
Abhi Kumar
e65c6aba57 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-01 17:15:30 +05:30
Abhi Kumar
6588a5bf6f chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-01 16:54:47 +05:30
Abhi Kumar
6b9ff40adc feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-01 16:54:41 +05:30
Abhi Kumar
5aa14fd406 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-01 16:06:39 +05:30
Abhi Kumar
82da0e7ca2 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-01 16:06:09 +05:30
Abhi Kumar
a84ddab840 fix(dashboard): wrap text panel background swatches in narrow panes
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
95639ebae9 style(dashboard): lighten section titles and steady config pane layout
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
7369a4c0d8 fix(dashboard): don't mark an explicit default precision as changed
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
cbbf568e0a refactor(dashboard): remove ConfigSegmented and segment icons
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
543836d183 feat(dashboard): add help text and card styling to thresholds and links
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
0393928e9a feat(dashboard): add text position grid and restyle text panel options
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
1e14be1d6f feat(dashboard): redesign buckets section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
9ba1c3586e feat(dashboard): redesign chart appearance section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
d0f434fcb9 feat(dashboard): redesign legend section and match series colors to legend font
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
a28598e818 feat(dashboard): redesign axes section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
ff234653e7 feat(dashboard): redesign formatting section with decimal tiles and preview
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:36 +05:30
Abhi Kumar
09d5d0ea87 feat(dashboard): redesign visualization section
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:35 +05:30
Abhi Kumar
c87ca68bb6 feat(dashboard): summarise config sections and mark changed settings
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:35 +05:30
Abhi Kumar
c3beb3fe44 feat(dashboard): add change tracking and shared config pane controls
Assisted-by: Claude Opus 5.5
2026-09-30 11:35:35 +05:30
94 changed files with 2763 additions and 968 deletions

View File

@@ -3379,6 +3379,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4042,6 +4049,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4056,6 +4064,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4068,6 +4077,7 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4141,6 +4151,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4220,6 +4242,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4393,6 +4425,87 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
color:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
size:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesSource:
enum:
- user

View File

@@ -8,6 +8,7 @@
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding-bottom: 44px;
@include custom-scrollbar;
@@ -42,11 +43,12 @@
.eyebrow {
display: block;
font-size: 11px;
font-weight: 600;
padding: 16px;
letter-spacing: 0.06em;
color: var(--l1-foreground);
padding: 18px 20px 12px;
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.group {
@@ -73,9 +75,5 @@
.sections {
display: flex;
flex-direction: column;
& > * {
padding: 0 16px;
border-top: 1px solid var(--l1-border);
}
border-bottom: 1px solid var(--l1-border);
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -7,6 +8,7 @@ import type {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -78,6 +80,11 @@ function ConfigPane({
(config) => config.kind !== SectionKind.PanelHeader,
);
const defaults = useMemo(
() => buildPluginSpec(definition.sections),
[definition],
);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
// Title/description are just a slice of the spec — edit them through the same
@@ -122,6 +129,7 @@ function ConfigPane({
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
@@ -147,6 +155,7 @@ function ConfigPane({
config={config}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}

View File

@@ -1,12 +1,5 @@
@use '../../../../../../styles/scrollbar' as *;
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.trigger {
display: flex;
align-items: center;

View File

@@ -2,13 +2,13 @@ import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
import { getPanelTypeDisabledReason } from './utils';
@@ -64,8 +64,7 @@ function PanelTypeSwitcher({
};
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigField label="Panel type" plain>
<button
type="button"
className={styles.trigger}
@@ -110,7 +109,7 @@ function PanelTypeSwitcher({
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</div>
</ConfigField>
);
}

View File

@@ -6,16 +6,20 @@ import {
type SectionConfig,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import type { SectionEditorContext } from '../sectionContext';
import { resolveSectionEditor } from '../sectionRegistry';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isChanged } from '../utils/changes';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** The kind's seeded plugin spec (`buildPluginSpec`). */
defaults: SeededPluginSpec;
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
bare?: boolean;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
@@ -52,6 +56,7 @@ function SectionSlot({
config,
spec,
onChangeSpec,
defaults,
bare,
legendSeries,
tableColumns,
@@ -104,8 +109,10 @@ function SectionSlot({
return null;
}
const { title, icon: Icon } = SECTION_METADATA[config.kind];
const { Component, get, update } = editor;
const { title } = SECTION_METADATA[config.kind];
const { Component, get, update, summarize } = editor;
const value = get(spec);
const defaultValue = getSectionDefault(editor, spec, defaults);
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
@@ -114,21 +121,26 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const context: SectionEditorContext = {
legendSeries,
yAxisUnit,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
};
const editorElement = (
<Component
value={get(spec)}
value={value}
defaultValue={defaultValue}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
{...context}
registerHeaderAction={registerHeaderAction}
/>
);
@@ -140,7 +152,8 @@ function SectionSlot({
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
summary={summarize?.(value, controls, context)}
changed={isChanged(value, defaultValue)}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}

View File

@@ -28,7 +28,12 @@ function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
makeSpec(initial),
);
return (
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
<SectionSlot
config={THRESHOLDS_CONFIG}
spec={spec}
defaults={{}}
onChangeSpec={setSpec}
/>
);
}
@@ -70,3 +75,48 @@ describe('SectionSlot header action', () => {
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
});
});
describe('SectionSlot header summary', () => {
it('summarises a collapsed section and marks it changed', async () => {
const user = userEvent.setup();
render(
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
);
await user.click(screen.getByTestId('config-section-thresholds'));
expect(screen.getByText('1 threshold')).toBeInTheDocument();
expect(screen.getByTestId('config-changed-dot')).toBeInTheDocument();
});
it('shows no summary or changed marker for an untouched section', () => {
render(<Harness />);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
expect(screen.queryByText(/threshold$/)).not.toBeInTheDocument();
});
});
describe('SectionSlot rendered defaults', () => {
it('does not mark an explicit value that matches what unset renders as', () => {
const spec = {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { decimalPrecision: '2' } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
render(
<SectionSlot
config={{ kind: SectionKind.Formatting, controls: { decimals: true } }}
spec={spec}
defaults={{}}
onChangeSpec={jest.fn()}
/>,
);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
});
});

View File

@@ -1,51 +1,53 @@
.section {
border-top: 1px solid var(--l1-border);
}
.header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 44px;
padding: 0 20px 0 0;
&:hover {
background: var(--l2-background);
}
}
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
.toggle {
display: flex;
flex: 1;
align-items: center;
gap: 11px;
gap: 10px;
min-width: 0;
padding: 0 !important;
padding: 18px 0 18px 20px;
border: none;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
}
.iconTile {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: none;
border-radius: 3px;
background: var(--l3-background);
color: var(--l3-foreground);
transition: all 0.15s ease;
}
.iconTileOpen {
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
color: var(--bg-robin-400);
}
.title {
font-size: 13px;
font-weight: 400;
white-space: nowrap;
color: var(--l1-foreground);
}
.summary {
flex: 1;
text-align: left;
font-weight: 600;
min-width: 0;
overflow: hidden;
font-size: 12px;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--l2-foreground);
}
.chevron {
flex: none;
color: var(--l3-foreground);
cursor: pointer;
transition: transform 0.15s ease;
&.open {
@@ -56,6 +58,6 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 2px 0 18px;
gap: 26px;
padding: 4px 20px 26px;
}

View File

@@ -1,18 +1,20 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import ChangedDot from '../controls/ChangedDot/ChangedDot';
import styles from './SettingsSection.module.scss';
interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
/** Controlled open state; when set, the section defers to `onOpenChange`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** One-line digest of the section's values, shown while collapsed. */
summary?: string;
changed?: boolean;
/** Rendered between the title and the chevron. */
headerSlot?: ReactNode;
children: ReactNode;
@@ -23,10 +25,11 @@ interface SettingsSectionProps {
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
open,
onOpenChange,
summary,
changed,
headerSlot,
children,
}: SettingsSectionProps): JSX.Element {
@@ -51,30 +54,18 @@ function SettingsSection({
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<span className={styles.title}>{title}</span>
{changed && <ChangedDot title="Has changed settings" />}
<span className={styles.summary}>{isOpen ? '' : summary}</span>
</button>
{headerSlot}
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
prefix={
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
tabIndex={-1}
<ChevronDown
size={14}
className={cx(styles.chevron, { [styles.open]: isOpen })}
onClick={toggle}
/>
</div>

View File

@@ -108,7 +108,7 @@ describe('ConfigPane', () => {
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,80 @@
.row {
display: flex;
align-items: center;
gap: 16px;
}
.grid {
display: grid;
flex: none;
grid-template-columns: repeat(3, 28px);
gap: 4px;
padding: 6px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.cell {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
cursor: pointer;
&:hover {
background: var(--l3-background);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.mark {
width: 10px;
height: 3px;
border-radius: 2px;
background: var(--l3-foreground);
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
.mark {
width: 14px;
background: var(--bg-robin-400);
}
}
.text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.label {
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,64 @@
import { useId } from 'react';
import cx from 'classnames';
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
import styles from './AlignmentGrid.module.scss';
interface AlignmentGridProps {
testId: string;
value: Alignment;
description?: string;
onChange: (next: Alignment) => void;
}
function AlignmentGrid({
testId,
value,
description,
onChange,
}: AlignmentGridProps): JSX.Element {
const name = useId();
return (
<div className={styles.row}>
<div
role="radiogroup"
aria-label="Text position"
data-testid={testId}
className={styles.grid}
>
{ALIGNMENT_CELLS.map((cell) => {
const label = alignmentLabel(cell);
const selected =
cell.textAlign === value.textAlign &&
cell.verticalAlign === value.verticalAlign;
return (
<label
key={label}
title={label}
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
className={cx(styles.cell, { [styles.selected]: selected })}
>
<input
type="radio"
className={styles.input}
name={name}
checked={selected}
aria-label={label}
onChange={(): void => onChange(cell)}
/>
<span className={styles.mark} />
</label>
);
})}
</div>
<div className={styles.text}>
<span className={styles.label}>{alignmentLabel(value)}</span>
{description && <span className={styles.description}>{description}</span>}
</div>
</div>
);
}
export default AlignmentGrid;

View File

@@ -0,0 +1,37 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
[DashboardtypesVerticalAlignDTO.top]: 'Top',
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
};
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
[DashboardtypesTextAlignDTO.left]: 'left',
[DashboardtypesTextAlignDTO.center]: 'center',
[DashboardtypesTextAlignDTO.right]: 'right',
};
export interface Alignment {
textAlign: DashboardtypesTextAlignDTO;
verticalAlign: DashboardtypesVerticalAlignDTO;
}
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
DashboardtypesVerticalAlignDTO,
).flatMap((verticalAlign) =>
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
textAlign,
verticalAlign,
})),
);
export function alignmentLabel({
textAlign,
verticalAlign,
}: Alignment): string {
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
}

View File

@@ -1,7 +1,8 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
gap: 8px 6px;
}
.swatch {

View File

@@ -8,31 +8,14 @@ import {
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import type {
PanelTheme,
TextBackgroundPreset,
TextBackgroundSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { BASE_TITLES, type BaseSelection, PRESET_TITLES } from './titles';
import styles from './BackgroundSwatches.module.scss';
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
/** Neither base swatch shows a colour, so its tooltip says what it does. */
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
none: 'Transparent — no card, border or title bar',

View File

@@ -0,0 +1,38 @@
import {
type ResolvedTextBackground,
TextBackgroundKind,
type TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
export const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
export type BaseSelection =
| TextBackgroundKind.None
| TextBackgroundKind.Default;
export const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
export function backgroundTitle(background: ResolvedTextBackground): string {
switch (background.kind) {
case TextBackgroundKind.Preset:
return background.preset ? PRESET_TITLES[background.preset] : 'Custom';
case TextBackgroundKind.Custom:
return `Custom ${background.surface?.toUpperCase() ?? ''}`.trim();
case TextBackgroundKind.None:
return BASE_TITLES.none;
default:
return BASE_TITLES.default;
}
}

View File

@@ -0,0 +1,7 @@
.dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,18 @@
import styles from './ChangedDot.module.scss';
function ChangedDot({
title = 'Changed from default',
}: {
title?: string;
}): JSX.Element {
return (
<span
className={styles.dot}
title={title}
aria-label={title}
data-testid="config-changed-dot"
/>
);
}
export default ChangedDot;

View File

@@ -0,0 +1,48 @@
.field {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.label {
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
color: var(--l2-foreground);
}
.plainLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.reset {
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 12px;
color: var(--bg-robin-400);
cursor: pointer;
&:hover {
color: var(--bg-robin-300);
}
}
.help {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import styles from './ConfigField.module.scss';
interface ConfigFieldProps {
label: ReactNode;
/** Shown under the control. */
help?: ReactNode;
changed?: boolean;
onReset?: () => void;
/** Plain body label instead of the uppercase field heading. */
plain?: boolean;
className?: string;
children: ReactNode;
}
function ConfigField({
label,
help,
changed,
onReset,
plain,
className,
children,
}: ConfigFieldProps): JSX.Element {
return (
<div className={cx(styles.field, className)}>
<div className={styles.header}>
<span className={plain ? styles.plainLabel : styles.label}>{label}</span>
{changed && onReset && (
<button
type="button"
className={styles.reset}
data-testid="config-field-reset"
onClick={onReset}
>
Reset
</button>
)}
</div>
{children}
{help && <span className={styles.help}>{help}</span>}
</div>
);
}
export default ConfigField;

View File

@@ -0,0 +1,5 @@
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import styles from './ConfigFieldRow.module.scss';
/** Side-by-side fields that wrap when the pane is narrow. */
function ConfigFieldRow({ children }: { children: ReactNode }): JSX.Element {
return <div className={styles.row}>{children}</div>;
}
export default ConfigFieldRow;

View File

@@ -0,0 +1,38 @@
import type { ChangeEvent } from 'react';
import { Input } from 'antd';
import ConfigField from '../ConfigField/ConfigField';
interface ConfigNumberInputProps {
testId: string;
label: string;
value: number | null | undefined;
onChange: (next: number | null) => void;
}
/** Numeric input where empty means "auto"; transient non-numeric input (e.g. "-") clears. */
function ConfigNumberInput({
testId,
label,
value,
onChange,
}: ConfigNumberInputProps): JSX.Element {
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
onChange(raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw));
};
return (
<ConfigField label={label} plain>
<Input
data-testid={testId}
type="number"
placeholder="Auto"
value={value ?? ''}
onChange={handleChange}
/>
</ConfigField>
);
}
export default ConfigNumberInput;

View File

@@ -1,10 +0,0 @@
.group {
width: 100%;
}
.segment {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}

View File

@@ -1,59 +0,0 @@
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSegmented.module.scss';
export interface ConfigSegmentedItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSegmentedProps<T extends string = string> {
testId: string;
value: T | undefined;
items: ConfigSegmentedItem[];
onChange: (value: T) => void;
}
/**
* Inline segmented control for short option sets in the config pane (line style, fill
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
* the Periscope ToggleGroup so it stays theme-faithful.
*/
function ConfigSegmented<T extends string = string>({
testId,
value,
items,
onChange,
}: ConfigSegmentedProps<T>): JSX.Element {
return (
<ToggleGroupSimple
type="single"
testId={testId}
className={styles.group}
value={value}
items={items.map((item) => ({
value: item.value,
'aria-label': item.label,
label: (
<span className={styles.segment}>
{item.icon && <SegmentIcon name={item.icon} />}
{item.label}
</span>
),
}))}
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
onChange={(next: T): void => {
if (next) {
onChange(next);
}
}}
/>
);
}
export default ConfigSegmented;

View File

@@ -1,30 +1,31 @@
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l2-background-60);
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
gap: 3px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l2-foreground);
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
color: var(--l3-foreground);
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -1,39 +1,38 @@
import type { ReactNode } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import ChangedDot from '../ChangedDot/ChangedDot';
import styles from './ConfigSwitch.module.scss';
interface ConfigSwitchProps {
testId: string;
/** Shown uppercased as the card title. */
title: string;
/** Optional helper line under the title. */
description?: string;
sketch?: ReactNode;
changed?: boolean;
value: boolean;
onChange: (checked: boolean) => void;
}
/**
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
* description on the left and a Switch on the right. The standard presentation for
* on/off panel-config controls (e.g. "Show points").
*/
function ConfigSwitch({
testId,
title,
description,
sketch,
changed,
value,
onChange,
}: ConfigSwitchProps): JSX.Element {
return (
<div className={styles.card}>
{sketch}
<div className={styles.text}>
<span className={styles.title}>{title}</span>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
<span className={styles.title}>
{title}
{changed && <ChangedDot />}
</span>
{description && <span className={styles.description}>{description}</span>}
</div>
<Switch testId={testId} value={value} onChange={onChange} />
</div>

View File

@@ -0,0 +1,110 @@
.container {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.tile {
position: relative;
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
height: 52px;
padding: 0 14px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
color: var(--l2-foreground);
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
border-color 150ms,
background 150ms;
&:hover {
border-color: var(--l2-border);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
color: var(--l1-foreground);
&:hover {
border-color: var(--bg-robin-500);
}
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin stacked-tiles($columns) {
grid-template-columns: repeat($columns, minmax(0, 1fr));
.tile {
flex-direction: column;
justify-content: center;
gap: 10px;
height: 88px;
padding: 0 4px;
}
}
@container (min-width: 200px) {
.grid[data-count='2'] {
@include stacked-tiles(2);
}
}
@container (min-width: 216px) {
.grid[data-count='3'] {
@include stacked-tiles(3);
}
}
@container (min-width: 220px) {
.grid[data-count='4'] {
@include stacked-tiles(2);
}
}
@container (min-width: 420px) {
.grid[data-count='4'] {
@include stacked-tiles(4);
}
}
.grid.compact {
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 6px;
.tile {
justify-content: center;
height: 36px;
padding: 0 4px;
}
}

View File

@@ -0,0 +1,72 @@
import { type ReactNode, useId } from 'react';
import cx from 'classnames';
import styles from './ConfigTiles.module.scss';
export interface ConfigTileItem<T extends string = string> {
value: T;
label: string;
drawing?: ReactNode;
}
interface ConfigTilesProps<T extends string> {
testId: string;
value: T | undefined;
items: ConfigTileItem<T>[];
onChange: (value: T) => void;
/** Compact text-only tiles on one row (e.g. decimal places). */
compact?: boolean;
'aria-label'?: string;
}
/**
* Single-choice tiles. Columns follow the container width: rows of 4 fold to 2×2 and
* any row too narrow for its tiles becomes a full-width list.
*/
function ConfigTiles<T extends string>({
testId,
value,
items,
onChange,
compact,
'aria-label': ariaLabel,
}: ConfigTilesProps<T>): JSX.Element {
const name = useId();
return (
<div className={styles.container}>
<div
role="radiogroup"
aria-label={ariaLabel}
data-testid={testId}
data-count={items.length}
className={cx(styles.grid, { [styles.compact]: compact })}
>
{items.map((item) => {
const selected = item.value === value;
return (
<label
key={item.value}
data-testid={`${testId}-${item.value}`}
className={cx(styles.tile, { [styles.selected]: selected })}
>
<input
type="radio"
className={styles.input}
name={name}
value={item.value}
checked={selected}
aria-label={item.label}
onChange={(): void => onChange(item.value)}
/>
{item.drawing}
<span className={styles.label}>{item.label}</span>
</label>
);
})}
</div>
</div>
);
}
export default ConfigTiles;

View File

@@ -1,6 +1,5 @@
import { ColorPicker } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './LegendColors.module.scss';
@@ -41,9 +40,9 @@ function LegendColorRow({
data-testid={`legend-color-${label}`}
>
<span className={styles.swatch} style={{ backgroundColor: color }} />
<Typography.Text className={styles.label} title={label}>
<span className={styles.label} title={label}>
{label}
</Typography.Text>
</span>
</button>
</ColorPicker>
{isOverridden && (

View File

@@ -42,7 +42,9 @@
.label {
overflow: hidden;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
white-space: nowrap;
text-overflow: ellipsis;

View File

@@ -40,7 +40,7 @@ function LegendColors({
if (series.length === 0) {
return (
<Typography.Text className={styles.empty}>
Run the panel to customise series colors.
Run the query to set a color for each series.
</Typography.Text>
);
}

View File

@@ -15,7 +15,7 @@ describe('LegendColors', () => {
expect(
screen.queryByTestId('panel-editor-v2-legend-colors'),
).not.toBeInTheDocument();
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
expect(screen.getByText(/run the query/i)).toBeInTheDocument();
});
it('renders the search box once series are present', () => {

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
function LegendFrame({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={56}
height={30}
viewBox="0 0 56 30"
fill="none"
strokeLinecap="round"
style={{ flex: 'none' }}
aria-hidden
>
<rect
x={1}
y={1}
width={54}
height={28}
rx={2}
stroke="var(--l3-border)"
strokeWidth={1.4}
/>
{children}
</svg>
);
}
export default LegendFrame;

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
function SketchSvg({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={40}
height={22}
viewBox="0 0 40 22"
fill="none"
stroke="var(--l3-foreground)"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
export default SketchSvg;

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
export type Tone = 'robin' | 'sakura' | 'forest' | 'amber';
const TONE_COLOR: Record<Tone, string> = {
robin: 'var(--bg-robin-300)',
sakura: 'var(--bg-sakura-400)',
forest: 'var(--bg-forest-400)',
amber: 'var(--bg-amber-400)',
};
function TileSvg({
tone,
children,
}: {
tone: Tone;
children: ReactNode;
}): JSX.Element {
return (
<svg
width={64}
height={24}
viewBox="0 0 64 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: TONE_COLOR[tone], flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
export default TileSvg;

View File

@@ -0,0 +1,37 @@
import SketchSvg from './SketchSvg';
/** Muted sketches for on/off cards. */
export const SWITCH_SKETCHES = {
hideHeader: (
<SketchSvg>
<rect x={3} y={3} width={34} height={16} rx={2} />
<path d="M3 8 H37" strokeDasharray="3 3" />
</SketchSvg>
),
fillGaps: (
<SketchSvg>
<path d="M3 9 L11 7 L16 19 L26 19 L30 8 L37 6" />
</SketchSvg>
),
points: (
<SketchSvg>
<path d="M4 17 L14 9 L24 13 L36 6" />
{[
[4, 17],
[14, 9],
[24, 13],
[36, 6],
].map(([cx, cy]) => (
<circle key={cx} cx={cx} cy={cy} r={2} fill="var(--l3-foreground)" />
))}
</SketchSvg>
),
combine: (
<SketchSvg>
<rect x={4} y={10} width={6} height={10} />
<rect x={13} y={4} width={6} height={16} />
<rect x={22} y={8} width={6} height={12} />
<rect x={31} y={13} width={6} height={7} />
</SketchSvg>
),
} satisfies Record<string, JSX.Element>;

View File

@@ -0,0 +1,164 @@
import LegendFrame from './LegendFrame';
import TileSvg from './TileSvg';
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
const LINE = 'M4 17 L18 9 L30 14 L44 5 L60 11';
const POINTS = [
[4, 18],
[22, 8],
[40, 14],
[58, 6],
];
function interpDrawing(path: string): JSX.Element {
return (
<TileSvg tone="robin">
<path d={path} />
{POINTS.map(([cx, cy]) => (
<circle key={cx} cx={cx} cy={cy} r={2.2} {...FILLED} />
))}
</TileSvg>
);
}
export const TILE_DRAWINGS = {
lineSolid: (
<TileSvg tone="sakura">
<path d={LINE} />
</TileSvg>
),
lineDashed: (
<TileSvg tone="sakura">
<path d={LINE} strokeDasharray="5 5" />
</TileSvg>
),
interpLinear: interpDrawing('M4 18 L22 8 L40 14 L58 6'),
interpSpline: interpDrawing(
'M4 18 C 12 18, 14 8, 22 8 S 34 14, 40 14 S 52 6, 58 6',
),
interpStepBefore: interpDrawing('M4 18 V8 H22 V14 H40 V6 H58'),
interpStepAfter: interpDrawing('M4 18 H22 V8 H40 V14 H58 V6'),
fillNone: (
<TileSvg tone="forest">
<path d={LINE} />
</TileSvg>
),
fillSolid: (
<TileSvg tone="forest">
<path d={`${LINE} L60 22 L4 22Z`} {...FILLED} opacity={0.4} />
<path d={LINE} />
</TileSvg>
),
fillGradient: (
<TileSvg tone="forest">
<defs>
<linearGradient id="config-tile-fill-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity={0.55} />
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<path
d={`${LINE} L60 22 L4 22Z`}
fill="url(#config-tile-fill-gradient)"
stroke="none"
/>
<path d={LINE} />
</TileSvg>
),
gapsConnect: (
<TileSvg tone="amber">
<path d="M4 16 L14 11 L22 14" />
<path d="M22 14 L42 8" strokeDasharray="3 4" />
<path d="M42 8 L50 11 L60 6" />
</TileSvg>
),
gapsBreak: (
<TileSvg tone="amber">
<path d="M4 16 L14 11 L22 14" />
<path d="M42 8 L50 11 L60 6" />
</TileSvg>
),
scaleLinear: (
<TileSvg tone="robin">
<path d="M4 20 L60 4" />
</TileSvg>
),
scaleLog: (
<TileSvg tone="robin">
<path d="M4 20 C 8 8, 20 5, 60 4" />
</TileSvg>
),
barsSideBySide: (
<TileSvg tone="robin">
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />
<rect x={18} y={4} width={7} height={18} {...FILLED} />
<rect x={36} y={12} width={7} height={10} {...FILLED} opacity={0.5} />
<rect x={44} y={7} width={7} height={15} {...FILLED} />
</TileSvg>
),
barsStacked: (
<TileSvg tone="robin">
<rect x={14} y={13} width={11} height={9} {...FILLED} opacity={0.5} />
<rect x={14} y={4} width={11} height={8} {...FILLED} />
<rect x={38} y={15} width={11} height={7} {...FILLED} opacity={0.5} />
<rect x={38} y={8} width={11} height={6} {...FILLED} />
</TileSvg>
),
areaOverlap: (
<TileSvg tone="robin">
<path d="M4 15 L18 9 L32 13 L46 6 L60 9" />
<path d="M4 20 L18 15 L32 18 L46 13 L60 15" opacity={0.5} />
</TileSvg>
),
areaStacked: (
<TileSvg tone="robin">
<path
d="M4 22 L4 17 L18 15 L32 16 L46 14 L60 15 L60 22Z"
{...FILLED}
opacity={0.45}
/>
<path
d="M4 17 L18 15 L32 16 L46 14 L60 15 L60 8 L46 5 L32 9 L18 6 L4 10Z"
{...FILLED}
/>
</TileSvg>
),
areaPercent: (
<TileSvg tone="robin">
<path
d="M4 22 L4 12 L18 10 L32 13 L46 9 L60 11 L60 22Z"
{...FILLED}
opacity={0.45}
/>
<path d="M4 12 L18 10 L32 13 L46 9 L60 11 L60 2 L4 2Z" {...FILLED} />
</TileSvg>
),
legendBottom: (
<LegendFrame>
<path
d="M8 15 L18 10 L28 13 L38 7 L48 9"
stroke="var(--bg-robin-300)"
strokeWidth={2}
/>
<path
d="M8 23 H16 M22 23 H30 M36 23 H44"
stroke="var(--l2-foreground)"
strokeWidth={2.4}
/>
</LegendFrame>
),
legendRight: (
<LegendFrame>
<path
d="M6 20 L14 12 L22 16 L32 8"
stroke="var(--bg-robin-300)"
strokeWidth={2}
/>
<path
d="M40 9 H48 M40 15 H48 M40 21 H48"
stroke="var(--l2-foreground)"
strokeWidth={2.4}
/>
</LegendFrame>
),
} satisfies Record<string, JSX.Element>;

View File

@@ -1,145 +0,0 @@
/**
* Small glyph icons for the panel-editor segmented/select controls, ported from the
* Configure-panel design. They render at 14px and inherit `currentColor` so the
* surrounding control can dim them when unselected and brighten them when active.
*/
export type SegmentIconName =
| 'solid-line'
| 'dashed-line'
| 'fill-none'
| 'fill-solid'
| 'fill-gradient'
| 'pos-bottom'
| 'pos-right'
| 'scale-linear'
| 'scale-log'
| 'interp-linear'
| 'interp-spline'
| 'interp-step-before'
| 'interp-step-after';
function Svg({ children }: { children: React.ReactNode }): JSX.Element {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
export function SegmentIcon({
name,
}: {
name: SegmentIconName;
}): JSX.Element | null {
switch (name) {
case 'solid-line':
return (
<Svg>
<path d="M2 8 H14" />
</Svg>
);
case 'dashed-line':
return (
<Svg>
<path d="M2 8 H4.5" />
<path d="M6.75 8 H9.25" />
<path d="M11.5 8 H14" />
</Svg>
);
case 'fill-none':
return (
<Svg>
<path d="M2 11 L6 6 L10 9 L14 5" />
</Svg>
);
case 'fill-solid':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.85}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'fill-gradient':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.3}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'pos-bottom':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={2} y={9} width={12} height={2.5} {...FILLED} />
</Svg>
);
case 'pos-right':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={10.5} y={2.5} width={3.5} height={9} {...FILLED} />
</Svg>
);
case 'scale-linear':
return (
<Svg>
<path d="M2.5 13 L13.5 3" />
</Svg>
);
case 'scale-log':
return (
<Svg>
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
</Svg>
);
case 'interp-linear':
return (
<Svg>
<path d="M2 12 L6 5 L10 9 L14 4" />
</Svg>
);
case 'interp-spline':
return (
<Svg>
<path d="M2 12 C5 3, 9 3, 14 8" />
</Svg>
);
case 'interp-step-before':
return (
<Svg>
<path d="M2 6 H6 V10 H10 V4.5 H14" />
</Svg>
);
case 'interp-step-after':
return (
<Svg>
<path d="M2 10 H6 V5 H10 V9.5 H14" />
</Svg>
);
default:
return null;
}
}

View File

@@ -12,21 +12,32 @@ import {
type PanelChartAppearanceSlice,
type PanelFormattingSlice,
type PanelVisualizationSlice,
type SectionControlsOf,
type SectionEditorProps,
type SectionSpecMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import type { SectionEditorContext } from './sectionContext';
import AxesSection from './sections/AxesSection/AxesSection';
import { summarizeAxes } from './sections/AxesSection/summary';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import { summarizeBuckets } from './sections/BucketsSection/summary';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import { summarizeChartAppearance } from './sections/ChartAppearanceSection/summary';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import { DEFAULT_DECIMAL_PRECISION } from './sections/FormattingSection/options';
import { summarizeFormatting } from './sections/FormattingSection/summary';
import LegendSection from './sections/LegendSection/LegendSection';
import { summarizeLegend } from './sections/LegendSection/summary';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import { summarizeTextLayout } from './sections/TextLayoutSection/summary';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import { summarizeVisualization } from './sections/VisualizationSection/summary';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
import { countSummary } from './utils/summary';
type PanelSpec = DashboardtypesPanelSpecDTO;
@@ -39,6 +50,14 @@ export interface SectionDescriptor<K extends SectionKind> {
Component: ComponentType<SectionEditorProps<K>>;
get: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
update: (spec: PanelSpec, value: SectionSpecMap[K]) => PanelSpec;
/** Values an unset field renders with, when the kind's seed leaves it unset. */
renderedDefaults?: Partial<SectionSpecMap[K]>;
/** Collapsed-header digest of the slice. */
summarize?: (
value: SectionSpecMap[K] | undefined,
controls: SectionControlsOf<K>,
ctx: SectionEditorContext,
) => string;
}
// The plugin spec is a discriminated union over panel kinds; reading/writing a shared
@@ -78,12 +97,15 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
update: (spec, formatting): PanelSpec =>
updatePluginSlice(spec, 'formatting', formatting),
renderedDefaults: { decimalPrecision: DEFAULT_DECIMAL_PRECISION },
summarize: summarizeFormatting,
},
[SectionKind.Axes]: {
Component: AxesSection,
get: (spec): DashboardtypesAxesDTO | undefined =>
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeAxes,
},
[SectionKind.Legend]: {
Component: LegendSection,
@@ -91,6 +113,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
update: (spec, legend): PanelSpec =>
updatePluginSlice(spec, 'legend', legend),
summarize: summarizeLegend,
},
[SectionKind.ChartAppearance]: {
Component: ChartAppearanceSection,
@@ -98,6 +121,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
summarize: summarizeChartAppearance,
},
[SectionKind.Visualization]: {
Component: VisualizationSection,
@@ -105,6 +129,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
update: (spec, visualization): PanelSpec =>
updatePluginSlice(spec, 'visualization', visualization),
summarize: summarizeVisualization,
},
[SectionKind.Buckets]: {
Component: BucketsSection,
@@ -112,6 +137,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
summarize: summarizeBuckets,
},
[SectionKind.TextLayout]: {
Component: TextLayoutSection,
@@ -119,6 +145,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
update: (spec, presentation): PanelSpec =>
updatePluginSlice(spec, 'presentation', presentation),
summarize: summarizeTextLayout,
},
[SectionKind.PanelHeader]: {
Component: PanelHeaderSection,
@@ -135,6 +162,7 @@ export const SECTION_REGISTRY: {
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardtypesLinkDTO[] => spec.links || [],
update: (spec, links): PanelSpec => ({ ...spec, links }),
summarize: (links): string => countSummary(links, 'link'),
},
// One editor for every threshold variant (label / comparison / table); the kind's
// `controls.variant` picks the row editor + element shape. All persist to the same
@@ -145,6 +173,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
update: (spec, thresholds): PanelSpec =>
updatePluginSlice(spec, 'thresholds', thresholds),
summarize: (thresholds): string => countSummary(thresholds, 'threshold'),
},
};
@@ -159,12 +188,19 @@ export interface ErasedSectionDescriptor {
Component: ComponentType<
{
value: unknown;
defaultValue?: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
} & SectionEditorContext
>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;
renderedDefaults?: object;
summarize?: (
value: unknown,
controls: unknown,
ctx: SectionEditorContext,
) => string;
}
export function resolveSectionEditor(
@@ -174,3 +210,20 @@ export function resolveSectionEditor(
| ErasedSectionDescriptor
| undefined;
}
/** A slice's default, read through the same lens from the kind's seeded plugin spec. */
export function getSectionDefault(
editor: ErasedSectionDescriptor,
spec: PanelSpec,
defaults: SeededPluginSpec,
): unknown {
const seeded = editor.get({
...spec,
links: [],
plugin: { ...spec.plugin, spec: defaults },
} as PanelSpec);
if (!editor.renderedDefaults) {
return seeded;
}
return { ...editor.renderedDefaults, ...(seeded as object | undefined) };
}

View File

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

View File

@@ -1,21 +1,14 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './AxesSection.module.scss';
type SoftBound = 'softMin' | 'softMax';
const SCALE_OPTIONS = [
{ value: 'linear', label: 'Linear', icon: 'scale-linear' as const },
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
];
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import { AxisScale, SCALE_OPTIONS } from './options';
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
@@ -23,58 +16,53 @@ const SCALE_OPTIONS = [
*/
function AxesSection({
value,
defaultValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Axes>): JSX.Element {
// An empty field clears the bound (null); otherwise parse to a number, ignoring
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
const handleBound =
(bound: SoftBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
const reset = createFieldResetter(value, defaultValue, onChange);
return (
<>
{controls.minMax && (
<div className={styles.bounds}>
<div className={styles.field}>
<Typography.Text>Soft min</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-min"
type="number"
placeholder="Auto"
value={value?.softMin ?? ''}
onChange={handleBound('softMin')}
<ConfigField
label="Y-axis range"
help="The axis always shows at least this range. Data outside it still stretches the axis."
{...reset('softMin', 'softMax')}
>
<ConfigFieldRow>
<ConfigNumberInput
testId="panel-editor-v2-soft-min"
label="Min"
value={value?.softMin}
onChange={(softMin): void => onChange({ ...value, softMin })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Soft max</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-max"
type="number"
placeholder="Auto"
value={value?.softMax ?? ''}
onChange={handleBound('softMax')}
<ConfigNumberInput
testId="panel-editor-v2-soft-max"
label="Max"
value={value?.softMax}
onChange={(softMax): void => onChange({ ...value, softMax })}
/>
</div>
</div>
</ConfigFieldRow>
</ConfigField>
)}
{controls.logScale && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSegmented
<ConfigField
label="Y-axis scale"
help="Logarithmic spreads out values that span several orders of magnitude."
{...reset('isLogScale')}
>
<ConfigTiles
testId="panel-editor-v2-log-scale"
value={value?.isLogScale ? 'log' : 'linear'}
aria-label="Y-axis scale"
value={value?.isLogScale ? AxisScale.LOG : AxisScale.LINEAR}
items={SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, isLogScale: next === 'log' })
onChange({ ...value, isLogScale: next === AxisScale.LOG })
}
/>
</div>
</ConfigField>
)}
</>
);

View File

@@ -75,7 +75,7 @@ describe('AxesSection', () => {
/>,
);
await user.click(screen.getByText('Log'));
await user.click(screen.getByTestId('panel-editor-v2-log-scale-log'));
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
});

View File

@@ -0,0 +1,20 @@
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export enum AxisScale {
LINEAR = 'linear',
LOG = 'log',
}
export const SCALE_OPTIONS: ConfigTileItem<AxisScale>[] = [
{
value: AxisScale.LINEAR,
label: 'Linear',
drawing: TILE_DRAWINGS.scaleLinear,
},
{
value: AxisScale.LOG,
label: 'Logarithmic',
drawing: TILE_DRAWINGS.scaleLog,
},
];

View File

@@ -0,0 +1,26 @@
import type { DashboardtypesAxesDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { joinSummary } from '../../utils/summary';
function describeRange({ softMin, softMax }: DashboardtypesAxesDTO): string {
const hasMin = typeof softMin === 'number';
const hasMax = typeof softMax === 'number';
if (!hasMin && !hasMax) {
return 'auto range';
}
return `${hasMin ? softMin : 'auto'} to ${hasMax ? softMax : 'auto'}`;
}
export function summarizeAxes(
value: DashboardtypesAxesDTO | undefined,
controls: SectionControlsOf<SectionKind.Axes>,
): string {
return joinSummary([
controls.logScale && (value?.isLogScale ? 'Log' : 'Linear'),
controls.minMax && describeRange(value ?? {}),
]);
}

View File

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

View File

@@ -1,21 +1,14 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './BucketsSection.module.scss';
// The two numeric bounds of the histogram-buckets spec (derived from the BE DTO).
type NumericBound = keyof Pick<
DashboardtypesHistogramBucketsDTO,
'bucketCount' | 'bucketWidth'
>;
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import { createFieldResetter } from '../../utils/changes';
/**
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
@@ -24,55 +17,51 @@ type NumericBound = keyof Pick<
*/
function BucketsSection({
value,
defaultValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Buckets>): JSX.Element {
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
// ignoring transient non-numeric input by leaving it unset.
const handleNumber =
(bound: NumericBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
const reset = createFieldResetter(value, defaultValue, onChange);
return (
<>
{controls.count && (
<div className={styles.field}>
<Typography.Text>Bucket count</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-count"
type="number"
placeholder="Auto"
value={value?.bucketCount ?? ''}
onChange={handleNumber('bucketCount')}
/>
</div>
)}
{controls.width && (
<div className={styles.field}>
<Typography.Text>Bucket width</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-width"
type="number"
placeholder="Auto"
value={value?.bucketWidth ?? ''}
onChange={handleNumber('bucketWidth')}
/>
</div>
{(controls.count || controls.width) && (
<ConfigField
label="Buckets"
help="How values are grouped into bars. Leave on Auto to size buckets from the data."
{...reset('bucketCount', 'bucketWidth')}
>
<ConfigFieldRow>
{controls.count && (
<ConfigNumberInput
testId="panel-editor-v2-bucket-count"
label="Count"
value={value?.bucketCount}
onChange={(bucketCount): void => onChange({ ...value, bucketCount })}
/>
)}
{controls.width && (
<ConfigNumberInput
testId="panel-editor-v2-bucket-width"
label="Width"
value={value?.bucketWidth}
onChange={(bucketWidth): void => onChange({ ...value, bucketWidth })}
/>
)}
</ConfigFieldRow>
</ConfigField>
)}
{controls.mergeQueries && (
<ConfigSwitch
testId="panel-editor-v2-merge-queries"
title="Merge active queries"
description="Bucket all active queries together into one distribution"
title="Combine queries"
description="Puts every active query into one distribution. Hides the legend."
sketch={SWITCH_SKETCHES.combine}
changed={reset('mergeAllActiveQueries').changed}
value={value?.mergeAllActiveQueries ?? false}
onChange={(checked): void =>
onChange({ ...value, mergeAllActiveQueries: checked })
onChange={(mergeAllActiveQueries): void =>
onChange({ ...value, mergeAllActiveQueries })
}
/>
)}

View File

@@ -0,0 +1,15 @@
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
import { joinSummary } from '../../utils/summary';
export function summarizeBuckets(
value: DashboardtypesHistogramBucketsDTO | undefined,
): string {
const custom =
typeof value?.bucketCount === 'number' ||
typeof value?.bucketWidth === 'number';
return joinSummary([
custom ? 'Custom' : 'Auto',
value?.mergeAllActiveQueries && 'combined',
]);
}

View File

@@ -1,7 +1,27 @@
.field {
.inset {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.insetRow {
flex-direction: row;
align-items: center;
gap: 12px;
}
.insetLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.opacitySlider {
flex: 1;
min-width: 0;
}
.thresholdField {

View File

@@ -1,91 +1,31 @@
import { Typography } from '@signozhq/ui/typography';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
LINE_INTERPOLATION_HELP,
LINE_INTERPOLATION_OPTIONS,
LINE_STYLE_OPTIONS,
} from './options';
import { formatOpacity } from './utils';
import styles from './ChartAppearanceSection.module.scss';
const LINE_STYLE_OPTIONS = [
{
value: DashboardtypesLineStyleDTO.solid,
label: 'Solid',
icon: 'solid-line' as const,
},
{
value: DashboardtypesLineStyleDTO.dashed,
label: 'Dashed',
icon: 'dashed-line' as const,
},
];
const LINE_INTERPOLATION_OPTIONS = [
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Linear',
icon: <SegmentIcon name="interp-linear" />,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Spline',
icon: <SegmentIcon name="interp-spline" />,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
icon: <SegmentIcon name="interp-step-before" />,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
icon: <SegmentIcon name="interp-step-after" />,
},
];
const FILL_MODE_OPTIONS = [
{
value: DashboardtypesFillModeDTO.none,
label: 'None',
icon: 'fill-none' as const,
},
{
value: DashboardtypesFillModeDTO.solid,
label: 'Solid',
icon: 'fill-solid' as const,
},
{
value: DashboardtypesFillModeDTO.gradient,
label: 'Gradient',
icon: 'fill-gradient' as const,
},
];
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
(option) => option.value !== DashboardtypesFillModeDTO.none,
);
const FILL_OPACITY_STEP = 0.01;
function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
/**
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
@@ -93,84 +33,90 @@ function formatOpacity(opacity: number): string {
*/
function ChartAppearanceSection({
value,
defaultValue,
controls,
onChange,
stepInterval,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const interpolation = value?.lineInterpolation;
return (
<>
{controls.lineStyle && (
<div className={styles.field}>
<Typography.Text>Line style</Typography.Text>
<ConfigSegmented
<ConfigField label="Line" {...reset('lineStyle')}>
<ConfigTiles
testId="panel-editor-v2-line-style"
aria-label="Line style"
value={value?.lineStyle}
items={LINE_STYLE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
}
onChange={(lineStyle): void => onChange({ ...value, lineStyle })}
/>
</div>
</ConfigField>
)}
{controls.lineInterpolation && (
<div className={styles.field}>
<Typography.Text>Line interpolation</Typography.Text>
<ConfigSelect
<ConfigField
label="How points connect"
help={interpolation && LINE_INTERPOLATION_HELP[interpolation]}
{...reset('lineInterpolation')}
>
<ConfigTiles
testId="panel-editor-v2-line-interpolation"
placeholder="Select interpolation…"
value={value?.lineInterpolation}
aria-label="How points connect"
value={interpolation}
items={LINE_INTERPOLATION_OPTIONS}
onChange={(next): void =>
onChange({
...value,
lineInterpolation: next,
})
onChange={(lineInterpolation): void =>
onChange({ ...value, lineInterpolation })
}
/>
</div>
</ConfigField>
)}
{controls.fillMode && (
<div className={styles.field}>
<Typography.Text>Fill mode</Typography.Text>
<ConfigSegmented
<ConfigField
label="Area under the line"
{...reset('fillMode', 'fillOpacity')}
>
<ConfigTiles
testId="panel-editor-v2-fill-mode"
aria-label="Area under the line"
value={value?.fillMode}
items={
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
}
onChange={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
onChange={(fillMode): void => onChange({ ...value, fillMode })}
/>
</div>
)}
{controls.fillOpacity && (
<div className={styles.field}>
<Typography.Text>Fill opacity</Typography.Text>
<ConfigSlider
testId="panel-editor-v2-fill-opacity"
// The chart's own default, so the thumb starts where an unset fill renders.
value={resolveFillOpacity(value?.fillOpacity)}
min={0}
max={1}
step={FILL_OPACITY_STEP}
formatValue={formatOpacity}
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
/>
</div>
{controls.fillOpacity && (
<div className={cx(styles.inset, styles.insetRow)}>
<span className={styles.insetLabel}>Opacity</span>
<div className={styles.opacitySlider}>
<ConfigSlider
testId="panel-editor-v2-fill-opacity"
// The chart's own default, so the thumb starts where an unset fill renders.
value={resolveFillOpacity(value?.fillOpacity)}
min={0}
max={1}
step={FILL_OPACITY_STEP}
formatValue={formatOpacity}
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
/>
</div>
</div>
)}
</ConfigField>
)}
{controls.showPoints && (
<ConfigSwitch
testId="panel-editor-v2-show-points"
title="Show points"
description="Display individual data points on the chart"
description="Marks each sample the query returned."
sketch={SWITCH_SKETCHES.points}
changed={reset('showPoints').changed}
value={value?.showPoints ?? false}
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
onChange={(showPoints): void => onChange({ ...value, showPoints })}
/>
)}
@@ -179,6 +125,7 @@ function ChartAppearanceSection({
testId="panel-editor-v2-span-gaps"
value={value?.spanGaps}
stepInterval={stepInterval}
{...reset('spanGaps')}
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
)}

View File

@@ -1,24 +1,16 @@
import { useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import type { FieldResetProps } from '../../utils/changes';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import { DISCONNECT_MODE_OPTIONS, DisconnectValuesMode } from './options';
import { defaultDisconnectDuration } from './utils';
import styles from './ChartAppearanceSection.module.scss';
const DEFAULT_THRESHOLD = '1m';
enum DisconnectValuesMode {
NEVER = 'never',
THRESHOLD = 'threshold',
}
const MODE_OPTIONS = [
{ value: DisconnectValuesMode.NEVER, label: 'Never' },
{ value: DisconnectValuesMode.THRESHOLD, label: 'Threshold' },
];
interface DisconnectValuesFieldProps {
interface DisconnectValuesFieldProps extends FieldResetProps {
testId: string;
value: DashboardtypesSpanGapsDTO | undefined;
/** Query step interval (seconds): seeds the default threshold and floors it. */
@@ -26,13 +18,6 @@ interface DisconnectValuesFieldProps {
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
}
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
function defaultDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_THRESHOLD;
}
/**
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
* (only bridge gaps shorter than a duration). The threshold persists as a
@@ -43,6 +28,8 @@ function DisconnectValuesField({
testId,
value,
stepInterval,
changed,
onReset,
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
@@ -63,7 +50,7 @@ function DisconnectValuesField({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
fillLessThan: lastDuration ?? defaultDisconnectDuration(stepInterval),
});
return;
}
@@ -72,21 +59,24 @@ function DisconnectValuesField({
};
return (
<>
<div className={styles.field}>
<Typography.Text>Disconnect values</Typography.Text>
<ConfigSegmented
testId={testId}
value={
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
}
items={MODE_OPTIONS}
onChange={handleMode}
/>
</div>
<ConfigField
label="When data is missing"
help="Breaking the line makes outages and restarts visible."
changed={changed}
onReset={onReset}
>
<ConfigTiles
testId={testId}
aria-label="When data is missing"
value={
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
}
items={DISCONNECT_MODE_OPTIONS}
onChange={handleMode}
/>
{isThreshold && duration && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<div className={styles.inset}>
<span className={styles.insetLabel}>Break when a gap is longer than</span>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={duration}
@@ -97,7 +87,7 @@ function DisconnectValuesField({
/>
</div>
)}
</>
</ConfigField>
);
}

View File

@@ -30,16 +30,6 @@ function StatefulSpanGaps({
);
}
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(triggerTestId);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
const ALL_CONTROLS = {
lineStyle: true,
lineInterpolation: true,
@@ -109,7 +99,7 @@ describe('ChartAppearanceSection', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ fillOpacity: true }}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
@@ -126,7 +116,7 @@ describe('ChartAppearanceSection', () => {
render(
<ChartAppearanceSection
value={{ fillOpacity: 0.25 }}
controls={{ fillOpacity: true }}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
@@ -165,7 +155,7 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText('Gradient')).toBeInTheDocument();
});
it('writes the chosen line interpolation through the dropdown', async () => {
it('writes the chosen line interpolation through the tiles', async () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
@@ -175,7 +165,9 @@ describe('ChartAppearanceSection', () => {
/>,
);
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
await userEvent
.setup()
.click(screen.getByTestId('panel-editor-v2-line-interpolation-spline'));
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
});
@@ -205,7 +197,9 @@ describe('ChartAppearanceSection', () => {
/>,
);
expect(screen.getByText('Never')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-span-gaps-never'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
@@ -222,7 +216,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
@@ -284,7 +278,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
@@ -360,7 +354,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
@@ -388,7 +382,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
@@ -442,7 +436,7 @@ describe('ChartAppearanceSection', () => {
// Focus the input first so clicking Never also fires its blur (the toggle race).
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
await user.click(screen.getByText('Never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
@@ -453,8 +447,8 @@ describe('ChartAppearanceSection', () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',

View File

@@ -0,0 +1,101 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const LINE_STYLE_OPTIONS: ConfigTileItem<DashboardtypesLineStyleDTO>[] =
[
{
value: DashboardtypesLineStyleDTO.solid,
label: 'Solid',
drawing: TILE_DRAWINGS.lineSolid,
},
{
value: DashboardtypesLineStyleDTO.dashed,
label: 'Dashed',
drawing: TILE_DRAWINGS.lineDashed,
},
];
export const LINE_INTERPOLATION_OPTIONS: ConfigTileItem<DashboardtypesLineInterpolationDTO>[] =
[
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Straight',
drawing: TILE_DRAWINGS.interpLinear,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Smooth',
drawing: TILE_DRAWINGS.interpSpline,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
drawing: TILE_DRAWINGS.interpStepBefore,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
drawing: TILE_DRAWINGS.interpStepAfter,
},
];
export const LINE_INTERPOLATION_HELP: Record<
DashboardtypesLineInterpolationDTO,
string
> = {
[DashboardtypesLineInterpolationDTO.linear]:
'Joins each point with a straight line.',
[DashboardtypesLineInterpolationDTO.spline]:
'Curves through each point. Can overshoot between points.',
[DashboardtypesLineInterpolationDTO.step_before]:
'Jumps to each new value before its timestamp.',
[DashboardtypesLineInterpolationDTO.step_after]:
'Holds each value until the next point arrives.',
};
export const FILL_MODE_OPTIONS: ConfigTileItem<DashboardtypesFillModeDTO>[] = [
{
value: DashboardtypesFillModeDTO.none,
label: 'None',
drawing: TILE_DRAWINGS.fillNone,
},
{
value: DashboardtypesFillModeDTO.solid,
label: 'Solid',
drawing: TILE_DRAWINGS.fillSolid,
},
{
value: DashboardtypesFillModeDTO.gradient,
label: 'Gradient',
drawing: TILE_DRAWINGS.fillGradient,
},
];
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
export const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
(option) => option.value !== DashboardtypesFillModeDTO.none,
);
export enum DisconnectValuesMode {
NEVER = 'never',
THRESHOLD = 'threshold',
}
export const DISCONNECT_MODE_OPTIONS: ConfigTileItem<DisconnectValuesMode>[] = [
{
value: DisconnectValuesMode.NEVER,
label: 'Connect the line',
drawing: TILE_DRAWINGS.gapsConnect,
},
{
value: DisconnectValuesMode.THRESHOLD,
label: 'Break long gaps',
drawing: TILE_DRAWINGS.gapsBreak,
},
];

View File

@@ -0,0 +1,41 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PanelChartAppearanceSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { joinSummary } from '../../utils/summary';
import { FILL_MODE_OPTIONS, LINE_INTERPOLATION_OPTIONS } from './options';
function describeLine(value: PanelChartAppearanceSlice): string | undefined {
const interpolation = LINE_INTERPOLATION_OPTIONS.find(
(option) => option.value === value.lineInterpolation,
)?.label;
const dashed = value.lineStyle === DashboardtypesLineStyleDTO.dashed;
if (!interpolation) {
return dashed ? 'Dashed' : undefined;
}
return dashed ? `${interpolation}, dashed` : interpolation;
}
export function summarizeChartAppearance(
value: PanelChartAppearanceSlice | undefined,
controls: SectionControlsOf<SectionKind.ChartAppearance>,
): string {
const appearance = value ?? {};
const fill = FILL_MODE_OPTIONS.find(
(option) =>
option.value === (appearance.fillMode ?? DashboardtypesFillModeDTO.none),
);
return joinSummary([
(controls.lineStyle || controls.lineInterpolation) &&
describeLine(appearance),
controls.fillMode && fill && `${fill.label} fill`,
controls.showPoints && appearance.showPoints && 'points',
controls.spanGaps && appearance.spanGaps?.fillOnlyBelow && 'breaks gaps',
]);
}

View File

@@ -0,0 +1,14 @@
import { rangeUtil } from '@grafana/data';
const DEFAULT_DISCONNECT_DURATION = '1m';
/** The step interval (smallest meaningful gap), else 1m. */
export function defaultDisconnectDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_DISCONNECT_DURATION;
}
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}

View File

@@ -1,7 +1,13 @@
.list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 14px;
}
.help {
font-size: 13px;
line-height: 19px;
color: var(--l2-foreground);
}
/* --- Saved-link list item --- */
@@ -9,10 +15,15 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 6px;
gap: 14px;
padding: 8px 10px 8px 16px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
&:hover {
border-color: var(--l2-border);
}
}
.listItemText {
@@ -32,12 +43,13 @@
.listItemLabel {
font-size: 13px;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.listItemUrl {
font-size: 11px;
color: var(--text-vanilla-400);
font-family: 'Geist Mono', monospace;
font-size: 12px;
color: var(--l2-foreground);
}
.listItemActions {

View File

@@ -61,6 +61,10 @@ function ContextLinksSection({
return (
<div className={styles.list}>
<span className={styles.help}>
Links appear in a menu when someone clicks a data point. Variables pass the
clicked series and time range along.
</span>
{links.map((link, index) => (
<ContextLinkListItem
// Links have no stable id on the wire; index is the row identity here.
@@ -81,7 +85,7 @@ function ContextLinksSection({
data-testid="panel-editor-v2-add-link"
onClick={openAddDialog}
>
Add Context Link
Add link
</Button>
<ContextLinkDialog

View File

@@ -31,7 +31,7 @@ function ColumnUnits({
if (columns.length === 0) {
return (
<Typography.Text className={styles.columnUnitsHint}>
Run the panel to set per-column units.
Run the query to set a unit for each value column.
</Typography.Text>
);
}
@@ -50,7 +50,7 @@ function ColumnUnits({
<div className={styles.columnUnits}>
{columns.map((column) => (
<div className={styles.columnField} key={column.key}>
<Typography.Text>{column.label}</Typography.Text>
<span className={styles.columnLabel}>{column.label}</span>
<YAxisUnitSelector
data-testid={`panel-editor-v2-column-unit-${column.key}`}
placeholder="Select unit"

View File

@@ -0,0 +1,53 @@
import type { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import { resolveDecimalPrecision } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
import { formatPanelValue } from 'pages/DashboardPage/DashboardContainer/Panels/utils/formatPanelValue';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import type { FieldResetProps } from '../../utils/changes';
import {
DECIMAL_OPTIONS,
DECIMALS_PREVIEW_VALUE,
DEFAULT_DECIMAL_PRECISION,
} from './options';
import styles from './FormattingSection.module.scss';
interface DecimalsFieldProps extends FieldResetProps {
value: DashboardtypesPrecisionOptionDTO | undefined;
unit?: string;
onChange: (next: DashboardtypesPrecisionOptionDTO) => void;
}
function DecimalsField({
value = DEFAULT_DECIMAL_PRECISION,
unit,
changed,
onReset,
onChange,
}: DecimalsFieldProps): JSX.Element {
return (
<ConfigField label="Decimal places" changed={changed} onReset={onReset}>
<ConfigTiles
compact
testId="panel-editor-v2-decimals"
aria-label="Decimal places"
value={value}
items={DECIMAL_OPTIONS}
onChange={onChange}
/>
<div className={styles.preview}>
<span className={styles.previewLabel}>Shows as</span>
<span className={styles.previewValue} data-testid="decimals-preview">
{formatPanelValue(
DECIMALS_PREVIEW_VALUE,
unit,
resolveDecimalPrecision(value),
)}
</span>
</div>
</ConfigField>
);
}
export default DecimalsField;

View File

@@ -1,9 +1,3 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.unitSelector {
:global(.ant-select) {
width: 100%;
@@ -13,11 +7,31 @@
}
}
.preview {
display: flex;
align-items: baseline;
gap: 8px;
padding: 12px 14px;
border-radius: 4px;
background: var(--l2-background);
}
.previewLabel {
font-size: 12px;
color: var(--l2-foreground);
}
.previewValue {
font-family: 'Geist Mono', monospace;
font-size: 15px;
color: var(--l1-foreground);
}
// Stacked per-column unit pickers; each column keeps the standard field layout.
.columnUnits {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
:global(.ant-select) {
width: 100%;
@@ -31,10 +45,15 @@
.columnField {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
flex-direction: column;
gap: 6px;
}
.columnLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.columnUnitSelector {
flex: 1;
}

View File

@@ -1,5 +1,3 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import type {
@@ -7,28 +5,17 @@ import type {
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../../controls/ConfigField/ConfigField';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import ColumnUnits from './ColumnUnits';
import DecimalsField from './DecimalsField';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
value: DashboardtypesPrecisionOptionDTO;
label: string;
}[] = [
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1 decimal' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
];
/**
* Edits the `formatting` slice of a panel spec (unit + decimal precision). Which
* controls show is driven by the per-kind `controls` flags; the spec slice itself
@@ -36,16 +23,22 @@ const DECIMAL_OPTIONS: {
*/
function FormattingSection({
value,
defaultValue,
controls,
onChange,
tableColumns = [],
metricUnit,
}: FormattingSectionProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
return (
<>
{controls.unit && (
<div className={styles.field}>
<Typography.Text>Unit</Typography.Text>
<ConfigField
label="Unit"
help="Labels values, axis ticks and tooltips. With milliseconds, 1500 shows as 1.5 s."
{...reset('unit')}
>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid="panel-editor-v2-unit"
@@ -54,37 +47,29 @@ function FormattingSection({
initialValue={metricUnit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>
</ConfigField>
)}
{controls.decimals && (
<div className={styles.field}>
<Typography.Text>Decimals</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-decimals"
placeholder="Select decimals…"
value={value?.decimalPrecision}
items={DECIMAL_OPTIONS}
onChange={(next): void =>
onChange({
...value,
decimalPrecision: next,
})
}
/>
</div>
<DecimalsField
value={value?.decimalPrecision}
unit={value?.unit}
{...reset('decimalPrecision')}
onChange={(decimalPrecision): void =>
onChange({ ...value, decimalPrecision })
}
/>
)}
{controls.columnUnits && (
<div className={styles.field}>
<Typography.Text>Column units</Typography.Text>
<ConfigField label="Column units" {...reset('columnUnits')}>
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
metricUnit={metricUnit}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>
</ConfigField>
)}
</>
);

View File

@@ -1,17 +1,15 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId('panel-editor-v2-decimals');
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
async function pickDecimal(value: string): Promise<void> {
await userEvent
.setup()
.click(screen.getByTestId(`panel-editor-v2-decimals-${value}`));
}
describe('FormattingSection', () => {
@@ -51,7 +49,7 @@ describe('FormattingSection', () => {
/>,
);
await pickDecimal('Full');
await pickDecimal('full');
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
});
@@ -66,11 +64,45 @@ describe('FormattingSection', () => {
/>,
);
await pickDecimal('2 decimals');
await pickDecimal('3');
expect(onChange).toHaveBeenCalledWith({
unit: 'bytes',
decimalPrecision: '2',
decimalPrecision: '3',
});
});
it('previews a sample value at the chosen precision', () => {
render(
<FormattingSection
value={{ decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_1 }}
controls={{ decimals: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('decimals-preview')).toHaveTextContent('1,234.5');
});
it('resets a changed precision back to the default', async () => {
const onChange = jest.fn();
render(
<FormattingSection
value={{
unit: 'bytes',
decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_4,
}}
defaultValue={{}}
controls={{ decimals: true }}
onChange={onChange}
/>,
);
await userEvent.setup().click(screen.getByTestId('config-field-reset'));
expect(onChange).toHaveBeenCalledWith({
unit: 'bytes',
decimalPrecision: undefined,
});
});

View File

@@ -0,0 +1,19 @@
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
/** What an unset precision renders with. */
export const DEFAULT_DECIMAL_PRECISION =
DashboardtypesPrecisionOptionDTO.NUMBER_2;
export const DECIMALS_PREVIEW_VALUE = 1234.56789;
export const DECIMAL_OPTIONS: ConfigTileItem<DashboardtypesPrecisionOptionDTO>[] =
[
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4' },
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
];

View File

@@ -0,0 +1,33 @@
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import type {
PanelFormattingSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { joinSummary } from '../../utils/summary';
import { DEFAULT_DECIMAL_PRECISION } from './options';
function describeDecimals(precision: DashboardtypesPrecisionOptionDTO): string {
if (precision === DashboardtypesPrecisionOptionDTO.full) {
return 'full precision';
}
return precision === DashboardtypesPrecisionOptionDTO.NUMBER_1
? '1 decimal'
: `${precision} decimals`;
}
export function summarizeFormatting(
value: PanelFormattingSlice | undefined,
controls: SectionControlsOf<SectionKind.Formatting>,
): string {
const hasColumnUnits = Object.keys(value?.columnUnits ?? {}).length > 0;
return joinSummary([
controls.unit &&
(value?.unit ? getUniversalNameFromMetricUnit(value.unit) : 'No unit'),
controls.columnUnits && hasColumnUnits && 'Per-column units',
controls.decimals &&
describeDecimals(value?.decimalPrecision ?? DEFAULT_DECIMAL_PRECISION),
]);
}

View File

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

View File

@@ -1,32 +1,18 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import LegendColors from '../../controls/LegendColors/LegendColors';
import type { SectionEditorContext } from '../../sectionContext';
import styles from './LegendSection.module.scss';
import { createFieldResetter } from '../../utils/changes';
import { POSITION_OPTIONS } from './options';
type LegendSectionProps = SectionEditorProps<SectionKind.Legend> &
Pick<SectionEditorContext, 'legendSeries'>;
const POSITION_OPTIONS = [
{
value: DashboardtypesLegendPositionDTO.bottom,
label: 'Bottom',
icon: 'pos-bottom' as const,
},
{
value: DashboardtypesLegendPositionDTO.right,
label: 'Right',
icon: 'pos-right' as const,
},
];
/**
* Edits the `legend` slice of a panel spec: legend position and per-series color
* overrides. The colors control reads the panel's resolved series from context (the
@@ -34,38 +20,35 @@ const POSITION_OPTIONS = [
*/
function LegendSection({
value,
defaultValue,
controls,
onChange,
legendSeries,
}: LegendSectionProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
return (
<>
{controls.position && (
<div className={styles.field}>
<Typography.Text>Position</Typography.Text>
<ConfigSegmented
<ConfigField label="Position" {...reset('position')}>
<ConfigTiles
testId="panel-editor-v2-legend-position"
aria-label="Legend position"
items={POSITION_OPTIONS}
value={value?.position}
onChange={(next): void =>
onChange({
...value,
position: next as DashboardtypesLegendPositionDTO,
})
}
value={value?.position ?? defaultValue?.position}
onChange={(position): void => onChange({ ...value, position })}
/>
</div>
</ConfigField>
)}
{controls.colors && (
<div className={styles.field}>
<Typography.Text>Series colors</Typography.Text>
<ConfigField label="Series colors" {...reset('customColors')}>
<LegendColors
series={legendSeries ?? []}
value={value?.customColors}
onChange={(customColors): void => onChange({ ...value, customColors })}
/>
</div>
</ConfigField>
)}
</>
);

View File

@@ -16,8 +16,8 @@ describe('LegendSection', () => {
expect(
screen.getByTestId('panel-editor-v2-legend-position'),
).toBeInTheDocument();
expect(screen.getByText('Bottom')).toBeInTheDocument();
expect(screen.getByText('Right')).toBeInTheDocument();
expect(screen.getByText('Below chart')).toBeInTheDocument();
expect(screen.getByText('Right of chart')).toBeInTheDocument();
});
it('renders nothing when position is not enabled', () => {
@@ -40,7 +40,7 @@ describe('LegendSection', () => {
/>,
);
fireEvent.click(screen.getByText('Right'));
fireEvent.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
});
@@ -58,7 +58,7 @@ describe('LegendSection', () => {
/>,
);
fireEvent.click(screen.getByText('Right'));
fireEvent.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
expect(onChange).toHaveBeenCalledWith({
position: 'right',

View File

@@ -0,0 +1,18 @@
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const POSITION_OPTIONS: ConfigTileItem<DashboardtypesLegendPositionDTO>[] =
[
{
value: DashboardtypesLegendPositionDTO.bottom,
label: 'Below chart',
drawing: TILE_DRAWINGS.legendBottom,
},
{
value: DashboardtypesLegendPositionDTO.right,
label: 'Right of chart',
drawing: TILE_DRAWINGS.legendRight,
},
];

View File

@@ -0,0 +1,21 @@
import type { DashboardtypesLegendDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { countSummary, joinSummary } from '../../utils/summary';
import { POSITION_OPTIONS } from './options';
export function summarizeLegend(
value: DashboardtypesLegendDTO | undefined,
controls: SectionControlsOf<SectionKind.Legend>,
): string {
const position =
POSITION_OPTIONS.find((option) => option.value === value?.position) ??
POSITION_OPTIONS[0];
return joinSummary([
controls.position && position.label,
countSummary(Object.keys(value?.customColors ?? {}), 'custom color'),
]);
}

View File

@@ -4,19 +4,24 @@ import type {
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
function PanelHeaderSection({
value,
onChange,
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
const hide = value?.hide === true;
return (
<ConfigSwitch
testId="panel-header-hide"
title="Hide header"
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
value={value?.hide === true}
onChange={(hide): void => onChange({ ...value, hide })}
title="Hide panel header"
description="Removes the title strip on the dashboard. Drag and actions appear on hover."
sketch={SWITCH_SKETCHES.hideHeader}
changed={hide}
value={hide}
onChange={(next): void => onChange({ ...value, hide: next })}
/>
);
}

View File

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

View File

@@ -2,7 +2,6 @@ import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
resolveTextBackground,
@@ -19,23 +18,12 @@ import type {
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import AlignmentGrid from '../../controls/AlignmentGrid/AlignmentGrid';
import BackgroundSwatches from '../../controls/BackgroundSwatches/BackgroundSwatches';
import CustomBackgroundRow from '../../controls/BackgroundSwatches/CustomBackgroundRow';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
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' },
];
import { backgroundTitle } from '../../controls/BackgroundSwatches/titles';
import ConfigField from '../../controls/ConfigField/ConfigField';
import { createFieldResetter } from '../../utils/changes';
/**
* Edits the Text panel's `presentation` slice: body alignment and the card
@@ -43,33 +31,31 @@ const VERTICAL_OPTIONS = [
*/
function TextLayoutSection({
value,
defaultValue,
onChange,
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
const theme = useIsDarkMode() ? PanelTheme.Dark : PanelTheme.Light;
const background = resolveTextBackground(value?.background, theme);
const reset = createFieldResetter(value, defaultValue, onChange);
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 })}
<>
<ConfigField label="Text position" {...reset('textAlign', 'verticalAlign')}>
<AlignmentGrid
testId="text-layout-align"
description="Where the text sits inside the panel."
value={{
textAlign: value?.textAlign ?? DashboardtypesTextAlignDTO.left,
verticalAlign: value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top,
}}
onChange={(alignment): void => onChange({ ...value, ...alignment })}
/>
</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>
<div className={styles.field}>
<Typography.Text>Background</Typography.Text>
</ConfigField>
<ConfigField
label="Background"
help={`Selected: ${backgroundTitle(background)}`}
{...reset('background')}
>
<BackgroundSwatches
testId="text-layout-background"
label="Panel background"
@@ -91,8 +77,8 @@ function TextLayoutSection({
}
onChange={(hex): void => onChange({ ...value, background: hex })}
/>
</div>
</div>
</ConfigField>
</>
);
}

View File

@@ -4,6 +4,7 @@ import {
render as rtlRender,
type RenderResult,
screen,
within,
} from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
@@ -33,7 +34,7 @@ describe('TextLayoutSection', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Center'));
fireEvent.click(screen.getByTestId('text-layout-align-top-center'));
expect(onChange).toHaveBeenCalledWith({
...value,
@@ -45,7 +46,7 @@ describe('TextLayoutSection', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Bottom'));
fireEvent.click(screen.getByTestId('text-layout-align-bottom-left'));
expect(onChange).toHaveBeenCalledWith({
...value,
@@ -129,7 +130,7 @@ describe('TextLayoutSection', () => {
'#3A2A64',
);
expect(
screen
within(screen.getByRole('radiogroup', { name: 'Panel background' }))
.getAllByRole<HTMLInputElement>('radio')
.filter((swatch) => swatch.checked),
).toHaveLength(0);

View File

@@ -0,0 +1,24 @@
import {
DashboardtypesTextAlignDTO,
type DashboardtypesTextPresentationDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { resolveTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/resolveTextBackground';
import { PanelTheme } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { alignmentLabel } from '../../controls/AlignmentGrid/alignment';
import { backgroundTitle } from '../../controls/BackgroundSwatches/titles';
import { joinSummary } from '../../utils/summary';
export function summarizeTextLayout(
value: DashboardtypesTextPresentationDTO | undefined,
): string {
return joinSummary([
alignmentLabel({
textAlign: value?.textAlign ?? DashboardtypesTextAlignDTO.left,
verticalAlign: value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top,
}),
// Preset detection matches either theme's surface, so the theme is immaterial here.
`${backgroundTitle(resolveTextBackground(value?.background, PanelTheme.Dark))} background`,
]);
}

View File

@@ -1,32 +1,41 @@
.list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 14px;
}
.help {
font-size: 13px;
line-height: 19px;
color: var(--l2-foreground);
}
// ── View mode: compact summary row ──────────────────────────────────────────
.viewRow {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 4px 0 10px;
border: 1px solid var(--l2-border);
gap: 14px;
padding: 8px 10px 8px 16px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background-color: var(--l2-background);
border-radius: 6px;
&:hover {
border-color: var(--l2-border);
}
}
.viewValue {
flex: none;
font-family: 'Geist Mono', monospace;
font-size: 13px;
font-weight: 500;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.viewLabel {
overflow: hidden;
font-size: 12px;
color: var(--text-vanilla-400);
color: var(--l2-foreground);
white-space: nowrap;
text-overflow: ellipsis;
}
@@ -39,11 +48,11 @@
.editRow {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
gap: 14px;
padding: 16px;
border: 1px solid var(--bg-robin-500);
border-radius: 4px;
background-color: var(--l2-background);
border: 1px solid var(--bg-robin-400);
border-radius: 6px;
}
.field {
@@ -53,8 +62,8 @@
}
.fieldLabel {
font-size: 12px;
color: var(--text-vanilla-400);
font-size: 13px;
color: var(--l1-foreground);
}
.actions {
@@ -65,8 +74,8 @@
// ── Shared ──────────────────────────────────────────────────────────────────
.dot {
width: 12px;
height: 12px;
width: 10px;
height: 10px;
flex: none;
border-radius: 50%;
}

View File

@@ -25,6 +25,14 @@ import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
const HELP: Record<ThresholdVariant, string> = {
label:
'Draws a dashed line across the chart at a value, so you can see when data crosses it.',
comparison:
'Changes the number’s color when it crosses a value. Useful for green / amber / red status.',
table: 'Colors cells in a column when their value crosses a limit.',
};
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
const ADD_TESTID: Record<ThresholdVariant, string> = {
label: 'panel-editor-v2-add-threshold',
@@ -188,6 +196,7 @@ function ThresholdsSection({
return (
<div className={styles.list}>
<span className={styles.help}>{HELP[variant]}</span>
{thresholds.map(renderRow)}
<Button

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Check, Pencil, Trash2, X } from '@signozhq/icons';
import { Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from '../../ThresholdsSection.module.scss';
@@ -79,17 +79,15 @@ function ThresholdRowShell({
type="button"
variant="outlined"
color="secondary"
prefix={<X size={14} />}
data-testid={`${testIdPrefix}-discard-${index}`}
onClick={onDiscard}
>
Discard
Cancel
</Button>
<Button
type="button"
variant="solid"
color="primary"
prefix={<Check size={14} />}
data-testid={`${testIdPrefix}-save-${index}`}
onClick={onSave}
>

View File

@@ -1,21 +1,27 @@
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { EQueryType } from 'types/common/dashboard';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import type { SectionEditorContext } from '../../sectionContext';
import { STACK_MODE_OPTIONS } from './stackModeOptions';
import { createFieldResetter } from '../../utils/changes';
import {
BAR_LAYOUT_HELP,
BAR_LAYOUT_OPTIONS,
BarLayout,
STACK_MODE_HELP,
STACK_MODE_OPTIONS,
} from './options';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
Pick<
SectionEditorContext,
@@ -35,6 +41,7 @@ type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
*/
function VisualizationSection({
value,
defaultValue,
controls,
onChange,
panelKind,
@@ -43,6 +50,14 @@ function VisualizationSection({
queryType,
signal,
}: VisualizationSectionProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const timePreference =
value?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time;
const barLayout = value?.stackedBarChart
? BarLayout.STACKED
: BarLayout.SIDE_BY_SIDE;
const stack = value?.stack ?? defaultValue?.stack;
return (
<>
{controls.switchPanelKind && panelKind && onChangePanelKind && (
@@ -58,58 +73,69 @@ function VisualizationSection({
)}
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>
<ConfigField
label="Time range"
help={
timePreference === DashboardtypesTimePreferenceDTO.global_time
? 'Follows the dashboard time picker.'
: 'Pinned. This panel ignores the dashboard time picker.'
}
{...reset('timePreference')}
>
<ConfigSelect
testId="panel-editor-v2-time-preference"
placeholder="Select time scope…"
value={value?.timePreference}
value={timePreference}
items={TIME_PREFERENCE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
timePreference: next,
})
}
onChange={(next): void => onChange({ ...value, timePreference: next })}
/>
</div>
)}
{controls.stacking && (
<ConfigSwitch
testId="panel-editor-v2-stacked-bar-chart"
title="Stack series"
description="Stack bars from all series on top of each other"
value={value?.stackedBarChart ?? false}
onChange={(checked): void =>
onChange({ ...value, stackedBarChart: checked })
}
/>
)}
{controls.stackMode && (
<div className={styles.field}>
<Typography.Text>Stack series</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-stack-mode"
value={value?.stack}
items={STACK_MODE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, stack: next as DashboardtypesStackModeDTO })
}
/>
</div>
</ConfigField>
)}
{controls.fillSpans && (
<ConfigSwitch
testId="panel-editor-v2-fill-spans"
title="Fill gaps"
description="Fill gaps in data with 0 for continuity"
title="Treat missing data as 0"
description="Empty intervals plot as 0 instead of a gap. Pulls averages down."
sketch={SWITCH_SKETCHES.fillGaps}
changed={reset('fillSpans').changed}
value={value?.fillSpans ?? false}
onChange={(checked): void => onChange({ ...value, fillSpans: checked })}
onChange={(fillSpans): void => onChange({ ...value, fillSpans })}
/>
)}
{controls.stacking && (
<ConfigField
label="Series layout"
help={BAR_LAYOUT_HELP[barLayout]}
{...reset('stackedBarChart')}
>
<ConfigTiles
testId="panel-editor-v2-stacked-bar-chart"
aria-label="Series layout"
value={barLayout}
items={BAR_LAYOUT_OPTIONS}
onChange={(next): void =>
onChange({ ...value, stackedBarChart: next === BarLayout.STACKED })
}
/>
</ConfigField>
)}
{controls.stackMode && (
<ConfigField
label="Series layout"
help={stack && STACK_MODE_HELP[stack]}
{...reset('stack')}
>
<ConfigTiles
testId="panel-editor-v2-stack-mode"
aria-label="Series layout"
value={stack}
items={STACK_MODE_OPTIONS}
onChange={(next): void => onChange({ ...value, stack: next })}
/>
</ConfigField>
)}
</>
);
}

View File

@@ -112,7 +112,9 @@ describe('VisualizationSection', () => {
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-stacked-bar-chart'));
fireEvent.click(
screen.getByTestId('panel-editor-v2-stacked-bar-chart-stacked'),
);
expect(onChange).toHaveBeenCalledWith({
timePreference: 'global_time',
@@ -120,7 +122,7 @@ describe('VisualizationSection', () => {
});
});
it('writes the chosen stack mode through the segmented control', async () => {
it('writes the chosen stack mode through the tiles', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
@@ -132,7 +134,7 @@ describe('VisualizationSection', () => {
);
expect(screen.getByTestId('panel-editor-v2-stack-mode')).toBeInTheDocument();
await user.click(screen.getByText('Percent'));
await user.click(screen.getByTestId('panel-editor-v2-stack-mode-percent'));
expect(onChange).toHaveBeenCalledWith({
fillSpans: true,

View File

@@ -0,0 +1,58 @@
import { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export enum BarLayout {
SIDE_BY_SIDE = 'side',
STACKED = 'stacked',
}
export const BAR_LAYOUT_OPTIONS: ConfigTileItem<BarLayout>[] = [
{
value: BarLayout.SIDE_BY_SIDE,
label: 'Side by side',
drawing: TILE_DRAWINGS.barsSideBySide,
},
{
value: BarLayout.STACKED,
label: 'Stacked',
drawing: TILE_DRAWINGS.barsStacked,
},
];
export const BAR_LAYOUT_HELP: Record<BarLayout, string> = {
[BarLayout.SIDE_BY_SIDE]:
'Bars for each series sit next to each other, for comparing series.',
[BarLayout.STACKED]:
'Bars sit on top of each other, so the height shows the total.',
};
// `percent` rescales each x-slice to its column total; the y axis follows.
export const STACK_MODE_OPTIONS: ConfigTileItem<DashboardtypesStackModeDTO>[] =
[
{
value: DashboardtypesStackModeDTO.none,
label: 'Overlap',
drawing: TILE_DRAWINGS.areaOverlap,
},
{
value: DashboardtypesStackModeDTO.normal,
label: 'Stacked',
drawing: TILE_DRAWINGS.areaStacked,
},
{
value: DashboardtypesStackModeDTO.percent,
label: '100%',
drawing: TILE_DRAWINGS.areaPercent,
},
];
export const STACK_MODE_HELP: Record<DashboardtypesStackModeDTO, string> = {
[DashboardtypesStackModeDTO.none]:
'Each series is drawn from zero and may cover others.',
[DashboardtypesStackModeDTO.normal]:
'Series sit on top of each other, so the top edge shows the total.',
[DashboardtypesStackModeDTO.percent]:
'Each series shows its share of the total at every point in time.',
};

View File

@@ -1,10 +0,0 @@
import { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
// `percent` rescales each x-slice to its column total; the y axis follows.
export const STACK_MODE_OPTIONS: ConfigSegmentedItem[] = [
{ value: DashboardtypesStackModeDTO.none, label: 'None' },
{ value: DashboardtypesStackModeDTO.normal, label: 'Normal' },
{ value: DashboardtypesStackModeDTO.percent, label: 'Percent' },
];

View File

@@ -0,0 +1,24 @@
import type {
PanelVisualizationSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
export function summarizeVisualization(
value: PanelVisualizationSlice | undefined,
controls: SectionControlsOf<SectionKind.Visualization>,
{ panelKind }: SectionEditorContext,
): string {
const time = TIME_PREFERENCE_OPTIONS.find(
(option) => option.value === value?.timePreference,
);
return joinSummary([
panelKind && getPanelDefinition(panelKind).displayName,
controls.timePreference && (time?.label ?? TIME_PREFERENCE_OPTIONS[0].label),
]);
}

View File

@@ -2,11 +2,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
// Per-panel time scope. "Global Time" follows the dashboard's time picker; the rest pin
// Per-panel time scope. "Dashboard time" follows the dashboard's time picker; the rest pin
// the panel to a fixed relative window regardless of the dashboard range (V1 parity).
export const TIME_PREFERENCE_OPTIONS: ConfigSelectItem<DashboardtypesTimePreferenceDTO>[] =
[
{ value: DashboardtypesTimePreferenceDTO.global_time, label: 'Global Time' },
{
value: DashboardtypesTimePreferenceDTO.global_time,
label: 'Dashboard time',
},
{ value: DashboardtypesTimePreferenceDTO.last_5_min, label: 'Last 5 min' },
{ value: DashboardtypesTimePreferenceDTO.last_15_min, label: 'Last 15 min' },
{ value: DashboardtypesTimePreferenceDTO.last_30_min, label: 'Last 30 min' },

View File

@@ -0,0 +1,47 @@
import { createFieldResetter, isChanged } from '../changes';
describe('isChanged', () => {
it.each([
[undefined, undefined],
[false, undefined],
[null, undefined],
['', undefined],
[[], undefined],
['solid', 'solid'],
[{ fillOnlyBelow: false }, undefined],
[{ lineStyle: 'solid' }, { lineStyle: 'solid', fillMode: 'none' }],
])('treats %p over default %p as unchanged', (value, defaultValue) => {
expect(isChanged(value, defaultValue)).toBe(false);
});
it.each([
['dashed', 'solid'],
[true, undefined],
[[{ value: 1 }], undefined],
[{ customColors: { a: '#fff' } }, {}],
[{ lineStyle: 'dashed' }, { lineStyle: 'solid' }],
])('treats %p over default %p as changed', (value, defaultValue) => {
expect(isChanged(value, defaultValue)).toBe(true);
});
});
describe('createFieldResetter', () => {
it('restores only the named fields to their defaults', () => {
const onChange = jest.fn();
const reset = createFieldResetter(
{ softMin: 1, softMax: 9, isLogScale: true },
{ isLogScale: false },
onChange,
);
const range = reset('softMin', 'softMax');
expect(range.changed).toBe(true);
range.onReset();
expect(onChange).toHaveBeenCalledWith({
softMin: undefined,
softMax: undefined,
isLogScale: true,
});
});
});

View File

@@ -0,0 +1,52 @@
import { isEqual, isPlainObject } from 'lodash-es';
/** `undefined`, `null`, `''`, `false` and `[]` all render as the control's default. */
function isUnset(value: unknown): boolean {
return (
value === undefined ||
value === null ||
value === '' ||
value === false ||
(Array.isArray(value) && value.length === 0)
);
}
/**
* Whether `value` differs from the default it renders over. Objects compare field-wise,
* so an unset or explicit-but-default field doesn't count.
*/
export function isChanged(value: unknown, defaultValue: unknown): boolean {
if (isPlainObject(value)) {
const defaults = (isPlainObject(defaultValue) ? defaultValue : {}) as Record<
string,
unknown
>;
return Object.entries(value as Record<string, unknown>).some(([key, field]) =>
isChanged(field, defaults[key]),
);
}
return !isUnset(value) && !isEqual(value, defaultValue);
}
export interface FieldResetProps {
changed: boolean;
onReset: () => void;
}
/** Binds a slice to its defaults; call with the field(s) one control owns. */
export function createFieldResetter<T extends object>(
value: T | undefined,
defaultValue: T | undefined,
onChange: (next: T) => void,
): (...keys: (keyof T)[]) => FieldResetProps {
return (...keys) => ({
changed: keys.some((key) => isChanged(value?.[key], defaultValue?.[key])),
onReset: (): void => {
const next = { ...value } as T;
keys.forEach((key) => {
next[key] = defaultValue?.[key] as T[keyof T];
});
onChange(next);
},
});
}

View File

@@ -0,0 +1,14 @@
export function countSummary(
items: unknown[] | undefined,
noun: string,
): string {
const count = items?.length ?? 0;
if (count === 0) {
return '';
}
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
export function joinSummary(parts: (string | false | undefined)[]): string {
return parts.filter(Boolean).join(' · ');
}

View File

@@ -213,7 +213,7 @@ export const SECTION_METADATA = {
[SectionKind.Legend]: { title: 'Legend', icon: Signpost },
[SectionKind.ChartAppearance]: { title: 'Chart appearance', icon: Palette },
[SectionKind.Visualization]: { title: 'Visualization', icon: Wallpaper },
[SectionKind.Buckets]: { title: 'Histogram / Buckets', icon: BarChart },
[SectionKind.Buckets]: { title: 'Buckets', icon: BarChart },
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
@@ -227,7 +227,12 @@ export const SECTION_METADATA = {
*/
export type SectionEditorProps<K extends SectionKind> = {
value: SectionSpecMap[K] | undefined;
/** The kind's seeded value for this slice; drives Reset and changed markers. */
defaultValue?: SectionSpecMap[K];
onChange: (next: SectionSpecMap[K]) => void;
} & (K extends ControlledSectionKind
? { controls: SectionControls[K] }
: unknown);
export type SectionControlsOf<K extends SectionKind> =
K extends ControlledSectionKind ? SectionControls[K] : undefined;

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
@@ -222,12 +222,10 @@ describe('PanelTypeSelectionModal', () => {
const { user } = renderDrawer();
await startNewSection(user);
await waitFor(() =>
expect(draft()).toStrictEqual({
title: '',
panelKind: 'signoz/TimeSeriesPanel',
}),
);
expect(draft()).toStrictEqual({
title: '',
panelKind: 'signoz/TimeSeriesPanel',
});
await user.type(screen.getByTestId('panel-section-name'), 'Errors');
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
@@ -286,7 +284,7 @@ describe('PanelTypeSelectionModal', () => {
await startNewSection(user);
await waitFor(() => expect(target()).toBeNull());
expect(target()).toBeNull();
});
it('restores the pre-reveal scroll position on cancel', async () => {

View File

@@ -1725,6 +1725,247 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
}
}
func TestScatterPlotPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
assert.Empty(t, spec.Dimensions.Color, "an omitted color stays empty so the renderer colours by every group-by label")
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","size":"","color":null}`, "expected empty dimensions in stored/response JSON")
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
}
func TestScatterPlotPanelRoundTrip(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {
"visualization": {"timePreference": "global_time"},
"dimensions": {"x": "A.count()", "y": "A.p99(duration_nano)", "size": "A.countIf(has_error = true)", "color": ["k8s.namespace.name", "k8s.pod.name"]},
"formatting": {"columnUnits": {"A.p99(duration_nano)": "ns"}, "decimalPrecision": "2"},
"axes": {"x": {"softMin": 0, "softMax": null, "scale": "log", "label": "Throughput"}, "y": {"softMin": null, "softMax": 1000, "scale": "symlog", "label": "p99 latency"}},
"chartAppearance": {"points": {"size": 8, "minSize": 4, "maxSize": 4, "opacity": 0.1}},
"legend": {"position": "bottom", "mode": "list", "customColors": {}},
"thresholds": [{"value": 300, "unit": "ms", "color": "#f00", "label": "p99 SLO"}]
}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "traces",
"aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}, {"expression": "countIf(has_error = true)"}],
"groupBy": [{"name": "k8s.namespace.name"}]
}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Equal(t, ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", Size: "A.countIf(has_error = true)", Color: []string{"k8s.namespace.name", "k8s.pod.name"}}, spec.Dimensions)
assert.Equal(t, "Throughput", spec.Axes.X.Label)
points := spec.ChartAppearance.Points
require.NotNil(t, points.Size)
require.NotNil(t, points.MinSize)
require.NotNil(t, points.MaxSize)
require.NotNil(t, points.Opacity)
assert.Equal(t, PointDiameter(8), *points.Size)
assert.Equal(t, PointDiameter(4), *points.MinSize, "minSize may equal maxSize")
assert.Equal(t, PointOpacity(0.1), *points.Opacity, "the opacity floor is inclusive")
assert.Equal(t, "p99 latency", spec.Axes.Y.Label)
assert.Equal(t, "log", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale log")
assert.Equal(t, "symlog", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale symlog")
require.NotNil(t, spec.Axes.X.SoftMin, "a zero softMin is a set value")
assert.Equal(t, 0.0, *spec.Axes.X.SoftMin)
require.NotNil(t, spec.Axes.Y.SoftMax)
assert.Equal(t, 1000.0, *spec.Axes.Y.SoftMax)
assert.Equal(t, "ns", spec.Formatting.ColumnUnits["A.p99(duration_nano)"])
require.Len(t, spec.Thresholds, 1)
assert.Equal(t, 300.0, spec.Thresholds[0].Value)
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
for _, want := range []string{
`"dimensions":{"x":"A.count()","y":"A.p99(duration_nano)","size":"A.countIf(has_error = true)","color":["k8s.namespace.name","k8s.pod.name"]}`,
`"x":{"softMin":0,"softMax":null,"scale":"log","label":"Throughput"}`,
`"chartAppearance":{"points":{"size":8,"minSize":4,"maxSize":4,"opacity":0.1}}`,
`"y":{"softMin":null,"softMax":1000,"scale":"symlog","label":"p99 latency"}`,
} {
assert.Contains(t, string(output), want, "expected stored/response JSON to contain %s", want)
}
}
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
},
{
scenario: "non-string axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"y": {"scale": true}}}`,
expectedErrorSubstring: "cannot unmarshal bool",
},
{
scenario: "isLogScale on a scatter plot axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "single-axis axes shape on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"softMin": 0}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "unknown dimension",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"z": "A"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "point size below the minimum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
expectedErrorSubstring: "invalid point size 1: must be a whole number between 2 and 40",
},
{
scenario: "max point size above the maximum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
expectedErrorSubstring: "invalid point size 41",
},
{
scenario: "fractional point size",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
expectedErrorSubstring: "invalid point size 4.5",
},
{
scenario: "min point size above max",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
},
{
scenario: "point opacity below the floor",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
},
{
scenario: "point opacity as a percentage",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
expectedErrorSubstring: "invalid point opacity 70",
},
{
scenario: "area fill fields on a scatter plot",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "a single colour key as a string",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"color": "service.name"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "panel-wide unit on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"formatting": {"unit": "ms"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "threshold without a color",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 300}]}`,
expectedErrorSubstring: "Thresholds[0].Color",
},
{
scenario: "scale on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"axes": {"scale": "log"}}`,
expectedErrorSubstring: "unknown field",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],

View File

@@ -28,15 +28,16 @@ type PanelPlugin struct {
// (and strips the duplicate parent properties) after reflection.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
})
}
@@ -69,6 +70,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
}
}
@@ -225,15 +227,16 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
@@ -249,15 +252,16 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindText: {},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindText: {},
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
}
)

View File

@@ -214,7 +214,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FillGaps: fillGaps,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
},
}, nil
}

View File

@@ -209,34 +209,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
}
})
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTable},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
t.Run(string(kind), func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: kind},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
},
},
},
},
}
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
}
})
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {

View File

@@ -2,6 +2,7 @@ package dashboardtypes
import (
"encoding/json"
"math"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
@@ -166,19 +167,20 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindScatterPlot}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -274,6 +276,69 @@ type TextPanelSpec struct {
HeaderOptions HeaderOptions `json:"headerOptions"`
}
type ScatterPlotPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Dimensions ScatterPlotDimensions `json:"dimensions"`
Formatting TableFormatting `json:"formatting"`
Axes ScatterPlotAxes `json:"axes"`
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
Legend Legend `json:"legend"`
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
// use the Table column key, so formatting.columnUnits carries over between the
// two kinds.
type ScatterPlotDimensions struct {
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
Size string `json:"size" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
Color []string `json:"color" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
}
type ScatterPlotAxes struct {
X ScatterPlotAxis `json:"x"`
Y ScatterPlotAxis `json:"y"`
}
type ScatterPlotAxis struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
Scale AxisScale `json:"scale"`
Label string `json:"label" description:"Axis title. Empty draws none."`
}
type ScatterPlotChartAppearance struct {
Points ScatterPlotPoints `json:"points"`
}
// ScatterPlotPoints keeps the fixed size and the size range side by side so
// binding or unbinding dimensions.size restores the other's last setting. Nil
// fields resolve to the renderer default.
type ScatterPlotPoints struct {
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.size is unset."`
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.size is set."`
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.size is set."`
Opacity *PointOpacity `json:"opacity"`
}
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
type alias ScatterPlotPoints
var tmp alias
if err := json.Unmarshal(data, &tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
}
*p = ScatterPlotPoints(tmp)
return p.validate()
}
func (p ScatterPlotPoints) validate() error {
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
}
return nil
}
type TextPresentation struct {
TextAlign TextAlign `json:"textAlign"`
VerticalAlign VerticalAlign `json:"verticalAlign"`
@@ -640,6 +705,47 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
}
}
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
// defined at and below zero.
type AxisScale struct{ valuer.String }
var (
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
AxisScaleLog = AxisScale{valuer.NewString("log")}
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
)
func (AxisScale) Enum() []any {
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
}
func (as AxisScale) ValueOrDefault() string {
if as.IsZero() {
return AxisScaleAuto.StringValue()
}
return as.StringValue()
}
func (as AxisScale) MarshalJSON() ([]byte, error) {
return json.Marshal(as.ValueOrDefault())
}
func (as *AxisScale) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
}
val := AxisScale{valuer.NewString(v)}
switch val {
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
*as = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
}
}
type FillMode struct{ valuer.String }
var (
@@ -778,6 +884,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
return nil
}
const (
minPointDiameter = 2
maxPointDiameter = 40
)
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
type PointDiameter int
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
return nil
}
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point size: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
}
if v != math.Trunc(v) || v < minPointDiameter || v > maxPointDiameter {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point size %v: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
}
*d = PointDiameter(v)
return nil
}
const minPointOpacity = 0.1
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
type PointOpacity float64
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointOpacity).WithMaximum(1)
return nil
}
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
}
if v < minPointOpacity || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
}
*o = PointOpacity(v)
return nil
}
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`