Compare commits

...

22 Commits

Author SHA1 Message Date
Abhi Kumar
b576f7a1cd fix(dashboard): wrap text panel background swatches in narrow panes
Assisted-by: Claude Opus 5.5
2026-09-29 17:24:16 +05:30
Abhi Kumar
914393be1e style(dashboard): lighten section titles and steady config pane layout
Assisted-by: Claude Opus 5.5
2026-09-29 17:13:15 +05:30
Abhi Kumar
c476120794 fix(dashboard): don't mark an explicit default precision as changed
Assisted-by: Claude Opus 5.5
2026-09-29 17:13:12 +05:30
Abhi Kumar
506e0da61a refactor(dashboard): remove ConfigSegmented and segment icons
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:46 +05:30
Abhi Kumar
8ea660229f feat(dashboard): add help text and card styling to thresholds and links
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:46 +05:30
Abhi Kumar
ad13d5e05e feat(dashboard): add text position grid and restyle text panel options
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:46 +05:30
Abhi Kumar
61187a1325 feat(dashboard): redesign buckets section
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:46 +05:30
Abhi Kumar
0b8dee2c10 feat(dashboard): redesign chart appearance section
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
89320b9c3d feat(dashboard): redesign legend section and match series colors to legend font
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
c687019e36 feat(dashboard): redesign axes section
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
8cdfbafe32 feat(dashboard): redesign formatting section with decimal tiles and preview
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
aac809f280 feat(dashboard): redesign visualization section
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
670e5e3426 feat(dashboard): summarise config sections and mark changed settings
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
34cb506fb3 feat(dashboard): add change tracking and shared config pane controls
Assisted-by: Claude Opus 5.5
2026-09-29 16:56:45 +05:30
Abhi Kumar
61a6fb5d1f feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-09-27 17:22:24 +05:30
Abhi Kumar
c4a6ce85fa fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-09-27 17:06:36 +05:30
Abhi Kumar
45a062babc feat(dashboard): preview the new panel where the picker will add it 2026-09-27 17:06:25 +05:30
Abhi Kumar
276056c4ab feat(dashboard): preview a new section on the dashboard while naming it 2026-09-27 16:36:47 +05:30
Abhi Kumar
ffbba01e29 feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-09-27 16:36:47 +05:30
Abhi Kumar
01f7f6869a feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-09-27 16:36:46 +05:30
Abhi Kumar
de3c3c268f feat(dashboard): highlight the picker's target section behind the drawer 2026-09-27 16:36:46 +05:30
Abhi Kumar
9a602d015a feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-09-27 16:36:46 +05:30
140 changed files with 4627 additions and 1243 deletions

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';
@@ -25,6 +27,7 @@ interface ConfigPaneProps {
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
@@ -57,6 +60,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -76,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
@@ -120,11 +129,13 @@ function ConfigPane({
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
@@ -144,11 +155,13 @@ function ConfigPane({
config={config}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -1,6 +1,56 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
@use '../../../../../../styles/scrollbar' as *;
.trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 5px 5px 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
}
.triggerIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.triggerName {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--l1-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerAction {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 3px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
}
.revert {
align-self: flex-start;
}
.drawerBody {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,12 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
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 ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -15,33 +20,96 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Kind the panel was opened with; a revert button appears once it differs. */
originalPanelKind?: PanelKind;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
* Visualization-type selector (rendered inside the Visualization section): opens the
* panel type browser in a drawer. A type is disabled when the active query type or
* signal is incompatible with it — resolved through the capabilities guard.
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const [isOpen, setIsOpen] = useState(false);
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
const getDisabledReason = useCallback(
(kind: PanelKind): string | undefined =>
getPanelTypeDisabledReason({
kind,
queryType,
signal,
label: getPanelDefinition(kind).displayName,
}),
[queryType, signal],
);
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
const revertBlockedReason = canRevert
? getDisabledReason(originalPanelKind)
: undefined;
const handleSelect = (kind: PanelKind): void => {
setIsOpen(false);
if (kind !== panelKind) {
onChange(kind);
}
};
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
</div>
<ConfigField label="Panel type" plain>
<button
type="button"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
data-testid="panel-editor-v2-type-switcher"
>
<Icon size={14} className={styles.triggerIcon} />
<span className={styles.triggerName}>{displayName}</span>
<span className={styles.triggerAction}>
<ArrowRightLeft size={14} />
Change
</span>
</button>
{canRevert && (
<Button
variant="link"
color="primary"
size="sm"
prefix={<Undo2 />}
className={styles.revert}
disabled={!!revertBlockedReason}
title={revertBlockedReason}
onClick={(): void => onChange(originalPanelKind)}
testId="panel-editor-v2-type-revert"
>
Revert to {getPanelDefinition(originalPanelKind).displayName}
</Button>
)}
<DrawerWrapper
open={isOpen}
onOpenChange={setIsOpen}
title="Change panel type"
subTitle="Pick a visualization for this panel."
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser
selectedKind={panelKind}
onSelect={handleSelect}
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</ConfigField>
);
}

View File

@@ -1,22 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
const OPTIONS = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -28,14 +35,30 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
function renderSwitcher(
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-type-switcher'));
return onChange;
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
function disabledKinds(): (string | undefined)[] {
return Array.from(
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
)
.filter((el) => el.getAttribute('aria-disabled') === 'true')
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
}
describe('PanelTypeSwitcher', () => {
@@ -44,7 +67,8 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -57,83 +81,92 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
it('shows the current type and switches to the chosen one', () => {
const onChange = renderSwitcher();
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
);
fireEvent.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
it('does not fire onChange when the current type is picked again', () => {
const onChange = renderSwitcher();
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
fireEvent.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables types whose supported signals exclude the current signal', () => {
const onChange = renderSwitcher({ signal: TelemetrytypesSignalDTO.metrics });
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
fireEvent.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
renderSwitcher();
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
expect(disabledKinds()).toHaveLength(0);
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
renderSwitcher({ queryType: EQueryType.PROM });
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
});
describe('revert', () => {
it('is hidden while the type is the original one', () => {
renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
it('switches back to the original type', () => {
const onChange = renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
const revert = screen.getByTestId('panel-editor-v2-type-revert');
expect(revert).toHaveTextContent('Revert to Time Series');
fireEvent.click(revert);
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
});
it('is disabled when the original type no longer fits the query', () => {
renderSwitcher({
panelKind: 'signoz/TimeSeriesPanel',
originalPanelKind: 'signoz/ListPanel',
queryType: EQueryType.PROM,
});
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
});
});
});

View File

@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
*/
export function usePanelTypeSelectItems({
queryType,

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,12 +56,14 @@ function SectionSlot({
config,
spec,
onChangeSpec,
defaults,
bare,
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -103,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).
@@ -113,20 +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}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
{...context}
registerHeaderAction={registerHeaderAction}
/>
);
@@ -138,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

@@ -16,6 +16,8 @@ export interface SectionEditorContext {
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Kind the panel was opened with, offered as a revert target. */
originalPanelKind?: PanelKind;
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;

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,25 +1,35 @@
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,
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
| 'panelKind'
| 'onChangePanelKind'
| 'originalPanelKind'
| 'signal'
| 'queryType'
>;
/**
@@ -31,13 +41,23 @@ type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
*/
function VisualizationSection({
value,
defaultValue,
controls,
onChange,
panelKind,
onChangePanelKind,
originalPanelKind,
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 && (
@@ -47,63 +67,75 @@ function VisualizationSection({
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
signal={signal}
originalPanelKind={originalPanelKind}
onChange={onChangePanelKind}
/>
)}
{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

@@ -11,6 +11,8 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
@@ -110,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',
@@ -118,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(
@@ -130,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,
@@ -173,7 +177,7 @@ describe('VisualizationSection', () => {
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
it('renders the type switcher and switches kind when switchPanelKind is set', () => {
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
@@ -189,7 +193,8 @@ describe('VisualizationSection', () => {
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
fireEvent.click(screen.getByTestId('panel-editor-v2-type-switcher'));
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});

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

@@ -39,6 +39,7 @@ import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
import type { NewPanelTarget } from '../patchOps';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
@@ -58,8 +59,7 @@ interface QueryEditorBodyProps {
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -70,6 +70,7 @@ interface QueryEditorBodyProps {
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
}
/**
@@ -84,12 +85,13 @@ function QueryEditorBody({
panel,
savedPanel,
isNew = false,
layoutIndex,
target,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
originalPanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -133,7 +135,7 @@ function QueryEditorBody({
dashboardId,
panelId,
isNew,
layoutIndex,
target,
});
const panelKind = draft.spec.plugin.kind;
@@ -319,6 +321,7 @@ function QueryEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}

View File

@@ -23,6 +23,7 @@ interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
}
/**
@@ -35,12 +36,13 @@ function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
target,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
originalPanelKind,
}: StaticEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -54,7 +56,7 @@ function StaticEditorBody({
dashboardId,
panelId,
isNew,
layoutIndex,
target,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
@@ -122,6 +124,7 @@ function StaticEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}

View File

@@ -6,14 +6,33 @@ import {
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
parseNewPanelLayoutIndex,
parseNewPanelTarget,
} from '../newPanelRoute';
describe('newPanelRoute', () => {
it('round-trips kind + layoutIndex through the new-panel search', () => {
const search = newPanelSearch('signoz/ListPanel', 2);
const search = newPanelSearch('signoz/ListPanel', {
type: 'section',
layoutIndex: 2,
});
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
expect(parseNewPanelLayoutIndex(search)).toBe(2);
expect(parseNewPanelTarget(search)).toStrictEqual({
type: 'section',
layoutIndex: 2,
});
});
it.each([
{ type: 'root' as const },
{ type: 'newSection' as const, title: 'Errors & 5xx' },
])('round-trips a $type target', (target) => {
expect(
parseNewPanelTarget(newPanelSearch('signoz/TimeSeriesPanel', target)),
).toStrictEqual(target);
});
it('ignores a blank new section title', () => {
expect(parseNewPanelTarget('?newSection=%20%20')).toBeUndefined();
});
it('omits layoutIndex when not provided', () => {
@@ -21,7 +40,7 @@ describe('newPanelRoute', () => {
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
'signoz/TimeSeriesPanel',
);
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
expect(parseNewPanelTarget(search)).toBeUndefined();
});
it('returns null for an existing panel id (not the new sentinel)', () => {

View File

@@ -14,15 +14,14 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { transferColumnWidths } from '../../Panels/utils/columnWidthStorage';
import { createPanelOps } from '../../patchOps';
import { createPanelOps, type NewPanelTarget } from '../../patchOps';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
}
interface UsePanelEditorSaveApi {
@@ -42,7 +41,7 @@ export function usePanelEditorSave({
dashboardId,
panelId,
isNew = false,
layoutIndex,
target,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
@@ -60,7 +59,7 @@ export function usePanelEditorSave({
savedPanelId = uuid();
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
target,
panelId: savedPanelId,
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
@@ -89,7 +88,7 @@ export function usePanelEditorSave({
});
return savedPanelId;
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
[dashboardId, panelId, isNew, target, patchAsync, queryClient],
);
return { save, isSaving: isPatching, error };

View File

@@ -2,6 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
@@ -18,8 +19,7 @@ export interface PanelEditorContainerProps {
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
target?: NewPanelTarget;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -38,6 +38,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
@@ -52,6 +53,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
/>
);
}
@@ -62,6 +64,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
/>
);
}

View File

@@ -4,26 +4,33 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { NewPanelTarget } from '../patchOps';
import { PANELS } from '../Panels/registry';
import {
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
// New (unsaved) panels use a fixed id segment, carrying kind + target in the query
// (`/panel/new?panelKind=…&layoutIndex=<index|root>` or `&newSection=<title>`).
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
const NEW_SECTION_PARAM = 'newSection';
const ROOT_LAYOUT = 'root';
/** Query string (incl. leading `?`) for the new-panel editor route. */
export function newPanelSearch(
panelKind: PanelKind,
layoutIndex?: number,
target?: NewPanelTarget,
): string {
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
if (layoutIndex !== undefined) {
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
if (target?.type === 'section') {
params.set(LAYOUT_INDEX_PARAM, String(target.layoutIndex));
} else if (target?.type === 'root') {
params.set(LAYOUT_INDEX_PARAM, ROOT_LAYOUT);
} else if (target?.type === 'newSection') {
params.set(NEW_SECTION_PARAM, target.title);
}
return `?${params.toString()}`;
}
@@ -75,12 +82,21 @@ export function buildExportPanelLink({
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
export function parseNewPanelTarget(
search: string,
): NewPanelTarget | undefined {
const params = new URLSearchParams(search);
const title = params.get(NEW_SECTION_PARAM)?.trim();
if (title) {
return { type: 'newSection', title };
}
const raw = params.get(LAYOUT_INDEX_PARAM);
if (raw === ROOT_LAYOUT) {
return { type: 'root' };
}
if (raw === null || raw === '') {
return undefined;
}
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
return Number.isNaN(n) ? undefined : { type: 'section', layoutIndex: n };
}

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

@@ -0,0 +1,184 @@
.browser {
display: flex;
flex-direction: column;
}
.controls {
position: sticky;
// Cover the drawer body's top padding so tiles don't peek above when stuck.
top: -16px;
z-index: 1;
display: flex;
flex-direction: column;
gap: 14px;
margin-top: -16px;
padding-block: 16px;
border-bottom: 1px dashed var(--l1-border);
// Matches the drawer surface, which the app pins to l1 for every drawer.
background: var(--l1-background);
}
.search {
box-sizing: border-box;
--input-wrapper-background: var(--l2-background);
--input-wrapper-border-color: var(--l2-border);
--input-hover-border-color: var(--l3-border);
--input-box-shadow: none;
}
.categories {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border: 1px solid var(--l1-border);
border-radius: 999px;
background: transparent;
color: var(--l2-foreground);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
&:hover {
color: var(--l1-foreground);
}
}
.categoryActive {
border-color: var(--l3-border);
background: var(--l3-background);
color: var(--l1-foreground);
}
.categoryCount {
color: var(--l3-foreground);
font-size: 10.5px;
}
.groups {
display: flex;
flex-direction: column;
gap: 24px;
padding-top: 20px;
}
.group {
display: flex;
flex-direction: column;
gap: 12px;
}
.groupLabel {
color: var(--bg-sienna-400);
font-size: 11px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--l1-border);
border-radius: 6px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
&:hover {
background: var(--l3-background);
}
}
.tileSelected {
border-color: var(--bg-robin-500);
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
background: var(--l3-background);
}
.tileDisabled {
opacity: 0.45;
cursor: not-allowed;
&:hover {
background: var(--l2-background);
}
}
.tileText {
display: flex;
flex-direction: column;
gap: 3px;
}
.tileTitle {
display: flex;
align-items: center;
gap: 7px;
}
.tileName {
color: var(--l1-foreground);
font-size: 13.5px;
font-weight: 500;
}
.newBadge {
padding: 2px 6px;
border: 1px solid var(--bg-sienna-400);
border-radius: 3px;
color: var(--bg-sienna-400);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.tileDescription {
color: var(--l2-foreground);
font-size: 12px;
line-height: 1.45;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 0;
color: var(--l2-foreground);
font-size: 13px;
text-align: center;
}
.clearSearch {
padding: 0;
border: none;
background: none;
color: var(--bg-robin-500);
font: inherit;
font-size: 12.5px;
cursor: pointer;
&:hover {
color: var(--bg-robin-400);
}
}

View File

@@ -0,0 +1,118 @@
import { useMemo, useState } from 'react';
import { Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import cx from 'classnames';
import type { PanelKind } from '../../../Panels/types/panelKind';
import {
filterPanelTypeGroups,
type PanelTypeGroupId,
} from './panelTypeCatalog';
import PanelTypeTile from './PanelTypeTile';
import styles from './PanelTypeBrowser.module.scss';
type CategoryId = PanelTypeGroupId | 'all';
interface PanelTypeBrowserProps {
selectedKind: PanelKind;
onSelect: (kind: PanelKind) => void;
getDisabledReason?: (kind: PanelKind) => string | undefined;
}
/** Searchable, category-filtered grid of panel types, grouped by purpose. */
function PanelTypeBrowser({
selectedKind,
onSelect,
getDisabledReason,
}: PanelTypeBrowserProps): JSX.Element {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<CategoryId>('all');
const matched = useMemo(() => filterPanelTypeGroups(query), [query]);
const categories = useMemo(
() => [
{
id: 'all' as const,
label: 'All',
count: matched.reduce((sum, group) => sum + group.items.length, 0),
},
...matched.map(({ id, label, items }) => ({
id,
label,
count: items.length,
})),
],
[matched],
);
const visibleGroups = matched.filter(
(group) =>
group.items.length > 0 && (category === 'all' || group.id === category),
);
return (
<div className={styles.browser}>
<div className={styles.controls}>
<Input
value={query}
onChange={(e): void => setQuery(e.target.value)}
placeholder="Search panel types"
prefix={<Search size={14} />}
testId="panel-type-search"
containerClassName={styles.search}
/>
<div className={styles.categories}>
{categories.map(({ id, label, count }) => (
<button
key={id}
type="button"
className={cx(styles.category, {
[styles.categoryActive]: category === id,
})}
aria-pressed={category === id}
onClick={(): void => setCategory(id)}
>
{label}
<span className={styles.categoryCount}>{count}</span>
</button>
))}
</div>
</div>
<div className={styles.groups}>
{visibleGroups.map((group) => (
<section key={group.id} className={styles.group}>
<span className={styles.groupLabel}>{group.label}</span>
<div className={styles.grid}>
{group.items.map((item) => (
<PanelTypeTile
key={item.kind}
item={item}
isSelected={item.kind === selectedKind}
disabledReason={getDisabledReason?.(item.kind)}
onSelect={(): void => onSelect(item.kind)}
/>
))}
</div>
</section>
))}
{visibleGroups.length === 0 && (
<div className={styles.empty}>
<span>No panel types match “{query}”</span>
<button
type="button"
className={styles.clearSearch}
onClick={(): void => setQuery('')}
>
Clear search
</button>
</div>
)}
</div>
</div>
);
}
export default PanelTypeBrowser;

View File

@@ -0,0 +1,124 @@
.preview {
height: 66px;
padding: 10px;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.svg {
display: block;
width: 100%;
height: 100%;
}
.bars,
.histogram {
display: flex;
align-items: flex-end;
height: 100%;
}
.bars {
gap: 7px;
}
.histogram {
gap: 2px;
}
.bar {
flex: 1;
border-radius: 1px;
background: var(--bg-robin-500);
}
.number {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
height: 100%;
}
.numberValue {
color: var(--l1-foreground);
font-size: 21px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.5px;
}
.numberUnit {
color: var(--l3-foreground);
font-size: 12px;
}
.numberLabel {
color: var(--l3-foreground);
font-size: 8px;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.rows {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.tableRow {
display: grid;
grid-template-columns: 1.4fr 1fr 0.7fr;
gap: 8px;
}
.listRow {
display: flex;
align-items: center;
gap: 6px;
.cell {
flex: 1;
}
}
.cell,
.headCell,
.accentCell {
display: block;
height: 4px;
border-radius: 2px;
}
.cell {
background: var(--l3-background);
}
.headCell {
background: var(--l3-border);
}
.accentCell {
background: var(--bg-robin-500);
opacity: 0.6;
}
.dot,
.accentDot {
width: 4px;
height: 4px;
border-radius: 50%;
}
.dot {
background: var(--l3-border);
}
.accentDot {
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,14 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPE_PREVIEWS } from './panelTypePreviews';
import styles from './PanelTypePreview.module.scss';
function PanelTypePreview({ kind }: { kind: PanelKind }): JSX.Element {
return (
<div className={styles.preview} aria-hidden>
{PANEL_TYPE_PREVIEWS[kind]}
</div>
);
}
export default PanelTypePreview;

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