mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-01 15:50:41 +01:00
Compare commits
20 Commits
feat/new-p
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119f59adce | ||
|
|
e65c6aba57 | ||
|
|
6588a5bf6f | ||
|
|
6b9ff40adc | ||
|
|
5aa14fd406 | ||
|
|
82da0e7ca2 | ||
|
|
a84ddab840 | ||
|
|
95639ebae9 | ||
|
|
7369a4c0d8 | ||
|
|
cbbf568e0a | ||
|
|
543836d183 | ||
|
|
0393928e9a | ||
|
|
1e14be1d6f | ||
|
|
9ba1c3586e | ||
|
|
d0f434fcb9 | ||
|
|
a28598e818 | ||
|
|
ff234653e7 | ||
|
|
09d5d0ea87 | ||
|
|
c87ca68bb6 | ||
|
|
c3beb3fe44 |
@@ -3379,6 +3379,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4042,6 +4049,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4056,6 +4064,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4068,6 +4077,7 @@ components:
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4141,6 +4151,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4220,6 +4242,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4393,6 +4425,87 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
color:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
size:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -7,6 +8,7 @@ import type {
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -78,6 +80,11 @@ function ConfigPane({
|
||||
(config) => config.kind !== SectionKind.PanelHeader,
|
||||
);
|
||||
|
||||
const defaults = useMemo(
|
||||
() => buildPluginSpec(definition.sections),
|
||||
[definition],
|
||||
);
|
||||
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
@@ -122,6 +129,7 @@ function ConfigPane({
|
||||
config={headerSection}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
@@ -147,6 +155,7 @@ function ConfigPane({
|
||||
config={config}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCallback, useState } from 'react';
|
||||
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import ConfigField from '../controls/ConfigField/ConfigField';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
@@ -64,8 +64,7 @@ function PanelTypeSwitcher({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigField label="Panel type" plain>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
@@ -110,7 +109,7 @@ function PanelTypeSwitcher({
|
||||
getDisabledReason={getDisabledReason}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
</div>
|
||||
</ConfigField>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,20 @@ import {
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import type { SectionEditorContext } from '../sectionContext';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
import { isChanged } from '../utils/changes';
|
||||
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
|
||||
|
||||
type SectionSlotProps = {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** The kind's seeded plugin spec (`buildPluginSpec`). */
|
||||
defaults: SeededPluginSpec;
|
||||
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
|
||||
bare?: boolean;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
|
||||
@@ -52,6 +56,7 @@ function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
defaults,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
@@ -104,8 +109,10 @@ function SectionSlot({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, icon: Icon } = SECTION_METADATA[config.kind];
|
||||
const { Component, get, update } = editor;
|
||||
const { title } = SECTION_METADATA[config.kind];
|
||||
const { Component, get, update, summarize } = editor;
|
||||
const value = get(spec);
|
||||
const defaultValue = getSectionDefault(editor, spec, defaults);
|
||||
// Atomic sections carry no `controls`; controlled ones do.
|
||||
const controls = 'controls' in config ? config.controls : undefined;
|
||||
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
|
||||
@@ -114,21 +121,26 @@ function SectionSlot({
|
||||
|
||||
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
|
||||
|
||||
const context: SectionEditorContext = {
|
||||
legendSeries,
|
||||
yAxisUnit,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
};
|
||||
|
||||
const editorElement = (
|
||||
<Component
|
||||
value={get(spec)}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
{...context}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
);
|
||||
@@ -140,7 +152,8 @@ function SectionSlot({
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
summary={summarize?.(value, controls, context)}
|
||||
changed={isChanged(value, defaultValue)}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px 6px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,10 +0,0 @@
|
||||
.group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.bounds {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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 ?? {}),
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
]);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { STACK_MODE_OPTIONS } from './stackModeOptions';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import {
|
||||
BAR_LAYOUT_HELP,
|
||||
BAR_LAYOUT_OPTIONS,
|
||||
BarLayout,
|
||||
STACK_MODE_HELP,
|
||||
STACK_MODE_OPTIONS,
|
||||
} from './options';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
|
||||
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
Pick<
|
||||
SectionEditorContext,
|
||||
@@ -35,6 +41,7 @@ type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
defaultValue,
|
||||
controls,
|
||||
onChange,
|
||||
panelKind,
|
||||
@@ -43,6 +50,14 @@ function VisualizationSection({
|
||||
queryType,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, defaultValue, onChange);
|
||||
const timePreference =
|
||||
value?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time;
|
||||
const barLayout = value?.stackedBarChart
|
||||
? BarLayout.STACKED
|
||||
: BarLayout.SIDE_BY_SIDE;
|
||||
const stack = value?.stack ?? defaultValue?.stack;
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
@@ -58,58 +73,69 @@ function VisualizationSection({
|
||||
)}
|
||||
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
<ConfigField
|
||||
label="Time range"
|
||||
help={
|
||||
timePreference === DashboardtypesTimePreferenceDTO.global_time
|
||||
? 'Follows the dashboard time picker.'
|
||||
: 'Pinned. This panel ignores the dashboard time picker.'
|
||||
}
|
||||
{...reset('timePreference')}
|
||||
>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-time-preference"
|
||||
placeholder="Select time scope…"
|
||||
value={value?.timePreference}
|
||||
value={timePreference}
|
||||
items={TIME_PREFERENCE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
timePreference: next,
|
||||
})
|
||||
}
|
||||
onChange={(next): void => onChange({ ...value, timePreference: next })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.stacking && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-stacked-bar-chart"
|
||||
title="Stack series"
|
||||
description="Stack bars from all series on top of each other"
|
||||
value={value?.stackedBarChart ?? false}
|
||||
onChange={(checked): void =>
|
||||
onChange({ ...value, stackedBarChart: checked })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.stackMode && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Stack series</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-stack-mode"
|
||||
value={value?.stack}
|
||||
items={STACK_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, stack: next as DashboardtypesStackModeDTO })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{controls.fillSpans && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-fill-spans"
|
||||
title="Fill gaps"
|
||||
description="Fill gaps in data with 0 for continuity"
|
||||
title="Treat missing data as 0"
|
||||
description="Empty intervals plot as 0 instead of a gap. Pulls averages down."
|
||||
sketch={SWITCH_SKETCHES.fillGaps}
|
||||
changed={reset('fillSpans').changed}
|
||||
value={value?.fillSpans ?? false}
|
||||
onChange={(checked): void => onChange({ ...value, fillSpans: checked })}
|
||||
onChange={(fillSpans): void => onChange({ ...value, fillSpans })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.stacking && (
|
||||
<ConfigField
|
||||
label="Series layout"
|
||||
help={BAR_LAYOUT_HELP[barLayout]}
|
||||
{...reset('stackedBarChart')}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId="panel-editor-v2-stacked-bar-chart"
|
||||
aria-label="Series layout"
|
||||
value={barLayout}
|
||||
items={BAR_LAYOUT_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, stackedBarChart: next === BarLayout.STACKED })
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{controls.stackMode && (
|
||||
<ConfigField
|
||||
label="Series layout"
|
||||
help={stack && STACK_MODE_HELP[stack]}
|
||||
{...reset('stack')}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId="panel-editor-v2-stack-mode"
|
||||
aria-label="Series layout"
|
||||
value={stack}
|
||||
items={STACK_MODE_OPTIONS}
|
||||
onChange={(next): void => onChange({ ...value, stack: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,9 @@ describe('VisualizationSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-stacked-bar-chart'));
|
||||
fireEvent.click(
|
||||
screen.getByTestId('panel-editor-v2-stacked-bar-chart-stacked'),
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
timePreference: 'global_time',
|
||||
@@ -120,7 +122,7 @@ describe('VisualizationSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the chosen stack mode through the segmented control', async () => {
|
||||
it('writes the chosen stack mode through the tiles', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
@@ -132,7 +134,7 @@ describe('VisualizationSection', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-stack-mode')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Percent'));
|
||||
await user.click(screen.getByTestId('panel-editor-v2-stack-mode-percent'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
fillSpans: true,
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
@@ -222,12 +222,10 @@ describe('PanelTypeSelectionModal', () => {
|
||||
const { user } = renderDrawer();
|
||||
|
||||
await startNewSection(user);
|
||||
await waitFor(() =>
|
||||
expect(draft()).toStrictEqual({
|
||||
title: '',
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
}),
|
||||
);
|
||||
expect(draft()).toStrictEqual({
|
||||
title: '',
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
|
||||
await user.type(screen.getByTestId('panel-section-name'), 'Errors');
|
||||
await user.click(screen.getByTestId('panel-type-signoz/TablePanel'));
|
||||
@@ -286,7 +284,7 @@ describe('PanelTypeSelectionModal', () => {
|
||||
|
||||
await startNewSection(user);
|
||||
|
||||
await waitFor(() => expect(target()).toBeNull());
|
||||
expect(target()).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the pre-reveal scroll position on cancel', async () => {
|
||||
|
||||
@@ -1725,6 +1725,247 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.Color, "an omitted color stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","size":"","color":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelRoundTrip(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {"timePreference": "global_time"},
|
||||
"dimensions": {"x": "A.count()", "y": "A.p99(duration_nano)", "size": "A.countIf(has_error = true)", "color": ["k8s.namespace.name", "k8s.pod.name"]},
|
||||
"formatting": {"columnUnits": {"A.p99(duration_nano)": "ns"}, "decimalPrecision": "2"},
|
||||
"axes": {"x": {"softMin": 0, "softMax": null, "scale": "log", "label": "Throughput"}, "y": {"softMin": null, "softMax": 1000, "scale": "symlog", "label": "p99 latency"}},
|
||||
"chartAppearance": {"points": {"size": 8, "minSize": 4, "maxSize": 4, "opacity": 0.1}},
|
||||
"legend": {"position": "bottom", "mode": "list", "customColors": {}},
|
||||
"thresholds": [{"value": 300, "unit": "ms", "color": "#f00", "label": "p99 SLO"}]
|
||||
}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "traces",
|
||||
"aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}, {"expression": "countIf(has_error = true)"}],
|
||||
"groupBy": [{"name": "k8s.namespace.name"}]
|
||||
}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Equal(t, ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", Size: "A.countIf(has_error = true)", Color: []string{"k8s.namespace.name", "k8s.pod.name"}}, spec.Dimensions)
|
||||
assert.Equal(t, "Throughput", spec.Axes.X.Label)
|
||||
points := spec.ChartAppearance.Points
|
||||
require.NotNil(t, points.Size)
|
||||
require.NotNil(t, points.MinSize)
|
||||
require.NotNil(t, points.MaxSize)
|
||||
require.NotNil(t, points.Opacity)
|
||||
assert.Equal(t, PointDiameter(8), *points.Size)
|
||||
assert.Equal(t, PointDiameter(4), *points.MinSize, "minSize may equal maxSize")
|
||||
assert.Equal(t, PointOpacity(0.1), *points.Opacity, "the opacity floor is inclusive")
|
||||
assert.Equal(t, "p99 latency", spec.Axes.Y.Label)
|
||||
assert.Equal(t, "log", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale log")
|
||||
assert.Equal(t, "symlog", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale symlog")
|
||||
require.NotNil(t, spec.Axes.X.SoftMin, "a zero softMin is a set value")
|
||||
assert.Equal(t, 0.0, *spec.Axes.X.SoftMin)
|
||||
require.NotNil(t, spec.Axes.Y.SoftMax)
|
||||
assert.Equal(t, 1000.0, *spec.Axes.Y.SoftMax)
|
||||
assert.Equal(t, "ns", spec.Formatting.ColumnUnits["A.p99(duration_nano)"])
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
assert.Equal(t, 300.0, spec.Thresholds[0].Value)
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
for _, want := range []string{
|
||||
`"dimensions":{"x":"A.count()","y":"A.p99(duration_nano)","size":"A.countIf(has_error = true)","color":["k8s.namespace.name","k8s.pod.name"]}`,
|
||||
`"x":{"softMin":0,"softMax":null,"scale":"log","label":"Throughput"}`,
|
||||
`"chartAppearance":{"points":{"size":8,"minSize":4,"maxSize":4,"opacity":0.1}}`,
|
||||
`"y":{"softMin":null,"softMax":1000,"scale":"symlog","label":"p99 latency"}`,
|
||||
} {
|
||||
assert.Contains(t, string(output), want, "expected stored/response JSON to contain %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point size 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point size 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "invalid point size 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"color": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Thresholds[0].Color",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -28,15 +28,16 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,6 +70,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,15 +227,16 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -249,15 +252,16 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -209,34 +209,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -166,19 +167,20 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -274,6 +276,69 @@ type TextPanelSpec struct {
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
Size string `json:"size" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
Color []string `json:"color" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.size restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.size is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.size is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.size is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
@@ -640,6 +705,47 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -778,6 +884,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point size: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v != math.Trunc(v) || v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point size %v: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
|
||||
Reference in New Issue
Block a user