Compare commits

...

8 Commits

Author SHA1 Message Date
Abhi Kumar
0aaf4222d2 fix(dashboards): align the fill opacity slider with its config section
Assisted-by: Claude Opus 5
2026-09-15 11:54:28 +05:30
Abhi Kumar
af705416e6 fix(dashboards): default a new Area panel to normal stacking
Assisted-by: Claude Opus 5
2026-09-15 11:54:24 +05:30
Abhi Kumar
8fd3ad9405 fix(charts): keep percent stacking within 0-100
Convert the running stack totals to percentages once they are final;
accumulating each slice's share drifted past 100 and released uPlot's
soft max, stretching the y axis to 110%.

Assisted-by: Claude Opus 5
2026-09-15 11:53:13 +05:30
Abhi Kumar
6a736c411d feat(dashboards): add the Area panel
Area gets its own PANEL_TYPES member rather than pointing at time series:
the reverse kind lookup is derived by inverting the kind→type map, so two
kinds on `graph` would steal TimeSeries' mapping.

Stacking stays a separate spec field from Bar's boolean, matching the wire
contract; a kind declares one of the two controls and a switch between them
translates the setting.

Assisted-by: Claude Opus 5
2026-09-15 11:00:30 +05:30
Abhi Kumar
ffdc012778 feat(charts): let a filled series declare its fill opacity
The default reproduces the alphas both fill modes hardcoded, so a series
that declares none renders byte-identically.

Assisted-by: Claude Opus 5
2026-09-15 10:59:50 +05:30
Naman Verma
dc1d43d1cb chore: remove comment 2026-09-15 10:57:39 +05:30
Naman Verma
86dfaab841 fix: remove area fill mode none 2026-09-15 10:57:39 +05:30
Naman Verma
4111f9ec62 feat: add plugin schema for area chart panel 2026-09-15 10:38:27 +05:30
43 changed files with 1835 additions and 109 deletions

View File

@@ -3199,6 +3199,53 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -3430,6 +3477,11 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -3892,6 +3944,7 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -3904,6 +3957,7 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
@@ -3915,6 +3969,7 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
@@ -3922,6 +3977,18 @@ components:
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -4252,6 +4319,12 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object

View File

@@ -4007,6 +4007,52 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -4084,6 +4130,11 @@ export interface DashboardtypesThresholdWithLabelDTO {
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
@@ -4096,6 +4147,27 @@ export enum DashboardtypesTimePreferenceDTO {
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -4793,29 +4865,6 @@ export enum DashboardtypesFillModeDTO {
gradient = 'gradient',
none = 'none',
}
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
fillMode?: DashboardtypesFillModeDTO;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
@@ -4868,6 +4917,18 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBarChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
/**
* @enum signoz/AreaChartPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
spec: DashboardtypesAreaChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
'signoz/NumberPanel' = 'signoz/NumberPanel',
}
@@ -5073,6 +5134,7 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
@@ -5997,6 +6059,7 @@ export interface DashboardtypesListableDashboardViewDTO {
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
'signoz/NumberPanel' = 'signoz/NumberPanel',
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
'signoz/TablePanel' = 'signoz/TablePanel',

View File

@@ -29,6 +29,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.LIST]:
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.AREA]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.

View File

@@ -374,6 +374,7 @@ export enum PANEL_TYPES {
LIST = 'list',
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',

View File

@@ -27,6 +27,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
LIST: false,
TRACE: false,
BAR: true,
AREA: true,
PIE: false,
HISTOGRAM: false,
TEXT: false,

View File

@@ -19,5 +19,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,
// Dashboards v2 renders this kind; the fallback only keeps the lookup exhaustive.
[PANEL_TYPES.AREA]: TimeSeriesPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
};

View File

@@ -3,6 +3,7 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
import { generateGradientFill } from '../utils/generateGradientFill';
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
import {
@@ -58,7 +59,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}: {
resolvedLineColor: string;
}): Partial<Series> {
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
const { lineWidth, lineStyle, lineCap, fillColor, fillMode, fillOpacity } =
this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? DEFAULT_LINE_WIDTH,
@@ -86,11 +88,17 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
lineConfig.fill = `${finalFillColor}70`;
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolvedOpacity)}`;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
generateGradientFill(
self,
finalFillColor,
'rgba(0, 0, 0, 0)',
resolvedOpacity,
);
}
}

View File

@@ -3,7 +3,7 @@ import uPlot from 'uplot';
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
import type { SeriesProps } from '../types';
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = (
@@ -362,4 +362,40 @@ describe('UPlotSeriesBuilder', () => {
expect(config.points?.filter).toBeUndefined();
expect(config.points?.show).toBe(true);
});
// Pins the alpha the solid fill hardcoded before opacity was configurable.
it('fills a solid series at the default opacity', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
fillColor: '#112233',
fillMode: FillMode.Solid,
}),
);
expect(builder.getConfig().fill).toBe('#11223370');
});
it('fills a solid series at the declared opacity', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
fillColor: '#112233',
fillMode: FillMode.Solid,
fillOpacity: 0.5,
}),
);
expect(builder.getConfig().fill).toBe('#11223380');
});
it('leaves an unfilled series without a fill', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
fillColor: '#112233',
fillMode: FillMode.None,
fillOpacity: 0.5,
}),
);
expect(builder.getConfig().fill).toBeUndefined();
});
});

View File

@@ -222,6 +222,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
spanGaps?: boolean | number;
fillColor?: string;
fillMode?: FillMode;
/** 01, for `Solid` and `Gradient`; unset uses `DEFAULT_FILL_OPACITY`. */
fillOpacity?: number;
isDarkMode?: boolean;
stepInterval?: number;
metric?: { [key: string]: string };

View File

@@ -0,0 +1,43 @@
import {
DEFAULT_FILL_OPACITY,
GRADIENT_MID_STOP_RATIO,
resolveFillOpacity,
toAlphaHex,
} from '../fillOpacity';
describe('resolveFillOpacity', () => {
it('falls back to the default for a missing or unusable value', () => {
expect(resolveFillOpacity(undefined)).toBe(DEFAULT_FILL_OPACITY);
expect(resolveFillOpacity(null)).toBe(DEFAULT_FILL_OPACITY);
expect(resolveFillOpacity(NaN)).toBe(DEFAULT_FILL_OPACITY);
});
it('keeps 0 rather than treating it as absent', () => {
expect(resolveFillOpacity(0)).toBe(0);
});
it('clamps to 01', () => {
expect(resolveFillOpacity(-0.5)).toBe(0);
expect(resolveFillOpacity(2)).toBe(1);
});
});
describe('toAlphaHex', () => {
// The alphas hardcoded before opacity was configurable.
it('reproduces the legacy solid alpha at the default opacity', () => {
expect(toAlphaHex(DEFAULT_FILL_OPACITY)).toBe('70');
});
it('reproduces the legacy gradient mid-stop alpha at the default opacity', () => {
expect(toAlphaHex(DEFAULT_FILL_OPACITY * GRADIENT_MID_STOP_RATIO)).toBe('40');
});
it('pads a single-digit alpha', () => {
expect(toAlphaHex(0)).toBe('00');
expect(toAlphaHex(0.02)).toBe('05');
});
it('maps a full opacity to ff', () => {
expect(toAlphaHex(1)).toBe('ff');
});
});

View File

@@ -0,0 +1,22 @@
/**
* `0x70 / 255` reproduces the alpha the fill modes hardcoded before opacity was
* configurable, so a series that declares none renders byte-identically.
*/
export const DEFAULT_FILL_OPACITY = 0x70 / 255;
/** Alpha ratio between a gradient's two stops, so it keeps its falloff at any opacity. */
export const GRADIENT_MID_STOP_RATIO = 0x40 / 0x70;
/** Clamps into 01; missing or non-finite falls back to the default. */
export function resolveFillOpacity(opacity?: number | null): number {
if (typeof opacity !== 'number' || !Number.isFinite(opacity)) {
return DEFAULT_FILL_OPACITY;
}
return Math.min(1, Math.max(0, opacity));
}
/** 01 opacity → the two-digit hex alpha suffix appended to an `#rrggbb` colour. */
export function toAlphaHex(opacity: number): string {
const alpha = Math.round(resolveFillOpacity(opacity) * 255);
return alpha.toString(16).padStart(2, '0');
}

View File

@@ -1,9 +1,16 @@
import uPlot from 'uplot';
import {
DEFAULT_FILL_OPACITY,
GRADIENT_MID_STOP_RATIO,
toAlphaHex,
} from './fillOpacity';
export function generateGradientFill(
uPlotInstance: uPlot,
startColor: string,
endColor: string,
opacity: number = DEFAULT_FILL_OPACITY,
): CanvasGradient {
const g = uPlotInstance.ctx.createLinearGradient(
0,
@@ -11,8 +18,11 @@ export function generateGradientFill(
0,
uPlotInstance.bbox.height,
);
g.addColorStop(0, `${startColor}70`);
g.addColorStop(0.6, `${startColor}40`);
g.addColorStop(0, `${startColor}${toAlphaHex(opacity)}`);
g.addColorStop(
0.6,
`${startColor}${toAlphaHex(opacity * GRADIENT_MID_STOP_RATIO)}`,
);
g.addColorStop(1, endColor);
return g;
}

View File

@@ -137,6 +137,15 @@ describe('stackSeries', () => {
]);
});
it('tops out at exactly 100 for values that do not divide cleanly', () => {
// Accumulating each slice's share drifts past 100 and stretches the y axis.
const data: AlignedData = [[1], [63], [78], [44]];
const [, top] = stackSeries(data, includeAll, StackMode.Percent).data;
expect(top[0]).toBe(100);
});
it('yields 0 across a column whose signed total cancels to zero', () => {
const data: AlignedData = [[1], [10], [-10]];

View File

@@ -42,45 +42,11 @@ interface BuildStackedSeriesParams {
mode: StackMode;
}
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
function columnTotals({
data,
valueSeriesCount,
pointCount,
omit,
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
const totals = Array(pointCount).fill(0) as number[];
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const rawValues = data[seriesIndex] as (number | null)[];
rawValues.forEach((rawValue, pointIndex) => {
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
});
}
return totals;
}
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
function toPercent(value: number, total: number): number {
return total === 0 ? 0 : (value / total) * 100;
}
/** What a raw value adds to the stack at a given point. */
type Contribution = (value: number, pointIndex: number) => number;
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
if (params.mode !== StackMode.Percent) {
return (value): number => value;
}
// Resolved up front: totals span series the accumulation below has not reached yet.
const totals = columnTotals(params);
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
@@ -93,14 +59,7 @@ function buildStackedSeries({
mode,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
const contributionOf = contributionForMode({
data,
valueSeriesCount,
pointCount,
omit,
mode,
});
const columnTotals = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
@@ -110,14 +69,27 @@ function buildStackedSeries({
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += contributionOf(
numericValue,
pointIndex,
));
return (columnTotals[pointIndex] += numericValue);
});
}
}
if (mode !== StackMode.Percent) {
return stackedSeries;
}
// Scale the running totals once they are final rather than accumulating per-slice
// percentages: the topmost series then divides the total by itself and lands on
// exactly 100, where accumulated shares drift past it and stretch the y axis.
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
if (omit(seriesIndex)) {
continue;
}
stackedSeries[seriesIndex - 1] = stackedSeries[seriesIndex - 1].map(
(value, pointIndex) => toPercent(value as number, columnTotals[pointIndex]),
);
}
return stackedSeries;
}

View File

@@ -0,0 +1,23 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// The design-system slider insets its track by half a thumb on each side so the
// fill follows the thumb's centre. Pull that inset back off the row so the track
// lines up with the other controls; the thumb never paints past the track edge.
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
}
.value {
flex-shrink: 0;
min-width: 36px;
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,47 @@
import { Slider } from '@signozhq/ui/slider';
import styles from './ConfigSlider.module.scss';
interface ConfigSliderProps {
testId: string;
value: number;
min: number;
max: number;
step: number;
/** Renders the current value beside the track (e.g. as a percentage). */
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
/**
* Numeric slider for the config sections. The design-system Slider is multi-thumb
* capable, so its callback hands back `number | number[]`; this narrows to one thumb.
*/
function ConfigSlider({
testId,
value,
min,
max,
step,
formatValue,
onChange,
}: ConfigSliderProps): JSX.Element {
return (
<div className={styles.row}>
<Slider
testId={testId}
className={styles.slider}
value={value}
min={min}
max={max}
step={step}
onChange={(next): void => onChange(Array.isArray(next) ? next[0] : next)}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : value}
</span>
</div>
);
}
export default ConfigSlider;

View File

@@ -2,16 +2,16 @@ import type { ComponentType } from 'react';
import type {
DashboardtypesLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
type AnyThreshold,
type PanelChartAppearanceSlice,
type PanelFormattingSlice,
type PanelVisualizationSlice,
type SectionEditorProps,
type SectionSpecMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
@@ -94,21 +94,15 @@ export const SECTION_REGISTRY: {
},
[SectionKind.ChartAppearance]: {
Component: ChartAppearanceSection,
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
spec,
'chartAppearance',
),
get: (spec): PanelChartAppearanceSlice | undefined =>
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
},
[SectionKind.Visualization]: {
Component: VisualizationSection,
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
spec,
'visualization',
),
get: (spec): PanelVisualizationSlice | undefined =>
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
update: (spec, visualization): PanelSpec =>
updatePluginSlice(spec, 'visualization', visualization),
},

View File

@@ -9,8 +9,11 @@ import type {
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 ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import type { SectionEditorContext } from '../../sectionContext';
@@ -72,10 +75,21 @@ const FILL_MODE_OPTIONS = [
},
];
// 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, point markers, and the connect-null-gaps threshold. Each
* control is gated by its `controls` flag.
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
* threshold. Each control is gated by its `controls` flag.
*/
function ChartAppearanceSection({
value,
@@ -124,7 +138,9 @@ function ChartAppearanceSection({
<ConfigSegmented
testId="panel-editor-v2-fill-mode"
value={value?.fillMode}
items={FILL_MODE_OPTIONS}
items={
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
}
onChange={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
@@ -132,6 +148,22 @@ function ChartAppearanceSection({
</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.showPoints && (
<ConfigSwitch
testId="panel-editor-v2-show-points"

View File

@@ -5,6 +5,7 @@ import {
DashboardtypesLineStyleDTO,
type DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DEFAULT_FILL_OPACITY } from 'lib/uPlotV2/utils/fillOpacity';
import ChartAppearanceSection from '../ChartAppearanceSection';
@@ -104,6 +105,66 @@ describe('ChartAppearanceSection', () => {
});
});
it('shows the fill opacity at the chart default when the spec omits it', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ fillOpacity: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-fill-opacity'),
).toBeInTheDocument();
expect(
screen.getByText(`${Math.round(DEFAULT_FILL_OPACITY * 100)}%`),
).toBeInTheDocument();
});
it('renders the stored fill opacity as a percentage', () => {
render(
<ChartAppearanceSection
value={{ fillOpacity: 0.25 }}
controls={{ fillOpacity: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('25%')).toBeInTheDocument();
});
it('offers no None fill mode to a kind that declares fill opacity', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Solid')).toBeInTheDocument();
expect(screen.getByText('Gradient')).toBeInTheDocument();
expect(screen.queryByText('None')).not.toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-fill-opacity'),
).toBeInTheDocument();
});
it('offers all three fill modes to a kind that can be unfilled', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ fillMode: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('None')).toBeInTheDocument();
expect(screen.getByText('Solid')).toBeInTheDocument();
expect(screen.getByText('Gradient')).toBeInTheDocument();
});
it('writes the chosen line interpolation through the dropdown', async () => {
const onChange = jest.fn();
render(

View File

@@ -1,14 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesStackModeDTO } 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 ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import type { SectionEditorContext } from '../../sectionContext';
import { STACK_MODE_OPTIONS } from './stackModeOptions';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
@@ -21,9 +24,10 @@ type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
/**
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
* flag, so a kind only renders — and only writes — the fields its spec supports.
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only),
* area stacking (`stack`, Area only) and gap filling (`fillSpans`). Each control is
* gated by its `controls` flag, so a kind only renders — and only writes — the fields
* its spec supports.
*/
function VisualizationSection({
value,
@@ -77,6 +81,20 @@ function VisualizationSection({
/>
)}
{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>
)}
{controls.fillSpans && (
<ConfigSwitch
testId="panel-editor-v2-fill-spans"

View File

@@ -1,6 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesStackModeDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import VisualizationSection from '../VisualizationSection';
@@ -115,6 +118,43 @@ describe('VisualizationSection', () => {
});
});
it('writes the chosen stack mode through the segmented control', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<VisualizationSection
value={{ fillSpans: true }}
controls={{ switchPanelKind: true, stackMode: true }}
onChange={onChange}
/>,
);
expect(screen.getByTestId('panel-editor-v2-stack-mode')).toBeInTheDocument();
await user.click(screen.getByText('Percent'));
expect(onChange).toHaveBeenCalledWith({
fillSpans: true,
stack: DashboardtypesStackModeDTO.percent,
});
});
it('renders no stack-mode control for a kind that declares bar stacking', () => {
render(
<VisualizationSection
value={undefined}
controls={{ switchPanelKind: true, stacking: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-stacked-bar-chart'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-stack-mode'),
).not.toBeInTheDocument();
});
it('toggles fill spans through onChange', () => {
const onChange = jest.fn();
render(

View File

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

View File

@@ -29,6 +29,7 @@ const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/AreaChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
@@ -41,6 +42,7 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TimeSeriesPanel': [metrics, logs, traces],
'signoz/BarChartPanel': [metrics, logs, traces],
'signoz/AreaChartPanel': [metrics, logs, traces],
'signoz/NumberPanel': [metrics, logs, traces],
'signoz/HistogramPanel': [metrics, logs, traces],
'signoz/PieChartPanel': [metrics, logs, traces],
@@ -72,6 +74,13 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/AreaChartPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/HistogramPanel': {
requestType: time_series,
formatTableResultForUI: false,

View File

@@ -0,0 +1,233 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesAreaChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'lib/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'lib/visualization/components/ChartManager/ChartManager';
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
import { PanelMode } from 'lib/visualization/panels/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
resolveStackMode,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
import { buildAreaChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function AreaChartPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/AreaChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const spec = useMemo<DashboardtypesAreaChartPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries),
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched — matters during drag-zoom transitions before
// new data arrives.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
sortSeriesByMeanDesc(
flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap ?? {},
),
),
[data.response, data.legendMap],
);
const config = useMemo(
() =>
buildAreaChartConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
// The standalone View modal shows V1's graph-manager legend below the chart:
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
const layoutChildren = useMemo(
() =>
panelMode === PanelMode.STANDALONE_VIEW ? (
<div className={PanelStyles.chartManagerContainer}>
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
onCancel={onCloseStandaloneView}
/>
</div>
) : null,
[
panelMode,
config,
chartData,
spec.formatting?.unit,
decimalPrecision,
onCloseStandaloneView,
],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
canDrilldown={!!enableDrillDown}
dismiss={dismiss}
/>
),
[panelId, enableDrillDown],
);
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
// settings aren't inherited — the only way to fully reset the uPlot instance.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
},
[onClick, flatSeries, builderQueries, data.response],
);
return (
<div
ref={graphRef}
data-testid="area-chart-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (
<TimeSeries
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
stack={resolveStackMode(spec.visualization?.stack)}
renderTooltipFooter={renderTooltipFooter}
onClick={enableDrillDown ? handleChartClick : undefined}
/>
)}
</div>
);
}
export default AreaChartPanelRenderer;

View File

@@ -0,0 +1,48 @@
import { ChartArea } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
kind: 'signoz/AreaChartPanel',
displayName: 'Area Chart',
mode: 'query',
icon: ChartArea,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,
clone: true,
download: { csv: false, png: true, svg: true },
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -0,0 +1,42 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
// Declaring `fillOpacity` also makes the kind always-filled: `fillMode` drops `none`
// and defaults to solid.
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: {
switchPanelKind: true,
timePreference: true,
stackMode: true,
fillSpans: true,
},
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
},
{
kind: SectionKind.ChartAppearance,
controls: {
lineStyle: true,
lineInterpolation: true,
fillMode: true,
fillOpacity: true,
showPoints: true,
spanGaps: true,
},
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -0,0 +1,160 @@
import type { DashboardtypesAreaChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
buildBaseConfig,
minStepInterval,
type TimeAxisChromeArgs,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
LINE_INTERPOLATION_MAP,
LINE_STYLE_MAP,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
import {
resolveAreaFillMode,
resolveSpanGaps,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildAreaChartConfigArgs extends TimeAxisChromeArgs {
spec: DashboardtypesAreaChartPanelSpecDTO;
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
}
/**
* Builds a `UPlotConfigBuilder` for an Area panel: shared scaffolding plus one filled
* series per result. Stacking is declared on the chart component instead, which hands
* it to the builder.
*/
export function buildAreaChartConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildAreaChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: true,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesAreaChartPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
/** Per-query step intervals (seconds); floor for a numeric spanGaps threshold. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
/**
* Adds one filled uPlot series per flattened V5 series; mutates the builder in place.
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: AddSeriesArgs): void {
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const resolvedSpanGaps = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance.spanGaps)
: true;
// A numeric spanGaps is a max-gap threshold (seconds); floor it at the step interval so a
// sub-step value doesn't break the line at every normal point. Boolean `true` passes through.
const minStep = stepIntervals ? minStepInterval(stepIntervals) : undefined;
const spanGaps =
typeof resolvedSpanGaps === 'number' && minStep !== undefined
? Math.max(minStep, resolvedSpanGaps)
: resolvedSpanGaps;
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]
: LineStyle.Solid;
const lineInterpolation = chartAppearance?.lineInterpolation
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
: LineInterpolation.Spline;
const fillMode = resolveAreaFillMode(chartAppearance?.fillMode);
// Null and undefined both mean "kind default", which the chart layer resolves.
const fillOpacity = chartAppearance?.fillOpacity ?? undefined;
series.forEach((s) => {
const hasSingleValidPoint = hasSingleVisiblePoint(s.values);
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
// A single visible point can't be drawn as a line — degrade to points
// so the user still sees the datum (matches V1 behavior).
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping,
spanGaps,
lineStyle,
lineInterpolation,
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
pointSize: DEFAULT_POINT_SIZE,
fillMode,
fillOpacity,
isDarkMode,
metric: s.labels,
});
});
}

View File

@@ -1,3 +1,4 @@
import { definition as AreaChart } from './kinds/AreaChartPanel/definition';
import { definition as BarChart } from './kinds/BarChartPanel/definition';
import { definition as Histogram } from './kinds/HistogramPanel/definition';
import { definition as NumberValue } from './kinds/NumberPanel/definition';
@@ -21,6 +22,7 @@ export const PANELS: PanelRegistry = {
[NumberValue.kind]: NumberValue,
[Table.kind]: Table,
[BarChart.kind]: BarChart,
[AreaChart.kind]: AreaChart,
[PieChart.kind]: PieChart,
[Histogram.kind]: Histogram,
[List.kind]: List,

View File

@@ -28,6 +28,11 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/AreaChartPanel': {
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };

View File

@@ -18,6 +18,7 @@ export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/AreaChartPanel': PANEL_TYPES.AREA,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
'signoz/PieChartPanel': PANEL_TYPES.PIE,
'signoz/TablePanel': PANEL_TYPES.TABLE,

View File

@@ -1,5 +1,7 @@
import type {
DashboardtypesLinkDTO,
DashboardtypesAreaChartAppearanceDTO,
DashboardtypesAreaChartVisualizationDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
@@ -87,15 +89,27 @@ export type AnyThreshold =
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
// Superset spanning every kind's chart-appearance DTO. Area's `fillMode` is a
// nominally distinct enum with the same members as TimeSeries', so the TimeSeries one
// types the shared control.
export type PanelChartAppearanceSlice =
DashboardtypesTimeSeriesChartAppearanceDTO &
Pick<DashboardtypesAreaChartAppearanceDTO, 'fillOpacity'>;
// Superset spanning every kind's visualization DTO. Bar and Area express stacking
// differently (`stackedBarChart` bool vs `stack` enum); a kind declares exactly one.
export type PanelVisualizationSlice = DashboardtypesBarChartVisualizationDTO &
Pick<DashboardtypesAreaChartVisualizationDTO, 'stack'>;
export interface SectionSpecMap {
[SectionKind.Formatting]: PanelFormattingSlice; // spec.plugin.spec.formatting
[SectionKind.Axes]: DashboardtypesAxesDTO; // spec.plugin.spec.axes
[SectionKind.Legend]: DashboardtypesLegendDTO; // spec.plugin.spec.legend
[SectionKind.ChartAppearance]: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
[SectionKind.ChartAppearance]: PanelChartAppearanceSlice; // spec.plugin.spec.chartAppearance
[SectionKind.Buckets]: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
// spec.plugin.spec.visualization — typed as the superset of every kind's shape;
// the `controls` bag gates which fields each kind writes.
[SectionKind.Visualization]: DashboardtypesBarChartVisualizationDTO;
[SectionKind.Visualization]: PanelVisualizationSlice;
[SectionKind.Thresholds]: AnyThreshold[]; // spec.plugin.spec.thresholds (variant picks the editor)
[SectionKind.ContextLinks]: DashboardtypesLinkDTO[]; // spec.links (PANEL-level)
[SectionKind.Columns]: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
@@ -124,6 +138,11 @@ export interface SectionControls {
lineStyle?: boolean;
lineInterpolation?: boolean;
fillMode?: boolean;
/**
* Declaring it also marks the kind always-filled: `fillMode` drops `none` to match
* the narrower `AreaFillMode` wire enum the save API validates against.
*/
fillOpacity?: boolean;
showPoints?: boolean;
spanGaps?: boolean;
};
@@ -133,12 +152,13 @@ export interface SectionControls {
mergeQueries?: boolean;
};
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
// 0 (TimeSeries).
// away from any panel); stacking → stackedBarChart (Bar); stackMode → stack
// (Area); fillSpans → fill gaps with 0 (TimeSeries / Area).
[SectionKind.Visualization]: {
switchPanelKind: boolean;
timePreference?: boolean;
stacking?: boolean;
stackMode?: boolean;
fillSpans?: boolean;
};
// Editor discriminator (not a spec field): which threshold variant a kind edits.

View File

@@ -5,12 +5,14 @@ import {
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesStackModeDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTimePreferenceDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultColumnsForSignal } from '../../../PanelEditor/ListColumnsEditor/selectFields';
import { sections as areaSections } from '../../kinds/AreaChartPanel/sections';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import {
@@ -178,6 +180,89 @@ describe('buildPluginSpec', () => {
});
});
it('translates Bar stacking into an Area stack mode', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, stackMode: true },
},
];
const oldSpec = oldSpecWith({ visualization: { stackedBarChart: true } });
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
stack: DashboardtypesStackModeDTO.normal,
});
});
it('translates an Area stack mode into Bar stacking, collapsing percent', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, stacking: true },
},
];
const fromPercent = oldSpecWith({
visualization: { stack: DashboardtypesStackModeDTO.percent },
});
expect(
buildPluginSpec(sections, { oldSpec: fromPercent }).visualization,
).toStrictEqual({ stackedBarChart: true });
const fromNone = oldSpecWith({
visualization: { stack: DashboardtypesStackModeDTO.none },
});
expect(
buildPluginSpec(sections, { oldSpec: fromNone }).visualization,
).toStrictEqual({ stackedBarChart: false });
});
it('defaults a stack-mode kind to normal when there is nothing to carry', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, stackMode: true },
},
];
expect(buildPluginSpec(sections).visualization).toStrictEqual({
stack: DashboardtypesStackModeDTO.normal,
});
expect(
buildPluginSpec(sections, { oldSpec: oldSpecWith({}) }).visualization,
).toStrictEqual({ stack: DashboardtypesStackModeDTO.normal });
});
it('carries an Area stack mode unchanged between Area panels', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, stackMode: true },
},
];
const oldSpec = oldSpecWith({
visualization: { stack: DashboardtypesStackModeDTO.percent },
});
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
stack: DashboardtypesStackModeDTO.percent,
});
});
it('seeds no stacking field when the target declares neither control', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
];
const oldSpec = oldSpecWith({
visualization: {
stackedBarChart: true,
stack: DashboardtypesStackModeDTO.percent,
},
});
expect(buildPluginSpec(sections, { oldSpec })).toStrictEqual({});
});
it('carries old legend position but never customColors', () => {
const sections: SectionConfig[] = [
{
@@ -264,6 +349,99 @@ describe('buildPluginSpec', () => {
expect(buildPluginSpec(sections)).toStrictEqual({});
});
it('defaults fillMode to solid for a kind that offers fill opacity', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { fillMode: true, fillOpacity: true },
},
];
expect(buildPluginSpec(sections).chartAppearance).toStrictEqual({
fillMode: DashboardtypesFillModeDTO.solid,
});
});
// `none` is absent from the AreaFillMode wire enum, so carrying it would fail the save.
it('coerces an unfilled source fillMode to solid for a filled kind', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { fillMode: true, fillOpacity: true },
},
];
const oldSpec = oldSpecWith({
chartAppearance: { fillMode: DashboardtypesFillModeDTO.none },
});
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
{
fillMode: DashboardtypesFillModeDTO.solid,
},
);
});
it('carries a filled source fillMode unchanged', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { fillMode: true, fillOpacity: true },
},
];
const oldSpec = oldSpecWith({
chartAppearance: { fillMode: DashboardtypesFillModeDTO.gradient },
});
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
{
fillMode: DashboardtypesFillModeDTO.gradient,
},
);
});
// TimeSeries keeps all three modes, so an Area -> TimeSeries switch needs no coercion.
it('leaves fillMode alone for a kind that can be unfilled', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.ChartAppearance, controls: { fillMode: true } },
];
const oldSpec = oldSpecWith({
chartAppearance: { fillMode: DashboardtypesFillModeDTO.none },
});
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
{
fillMode: DashboardtypesFillModeDTO.none,
},
);
});
it('carries fillOpacity only when the target declares it, including 0', () => {
const withOpacity: SectionConfig[] = [
{
kind: SectionKind.ChartAppearance,
controls: { fillMode: true, fillOpacity: true },
},
];
const withoutOpacity: SectionConfig[] = [
{ kind: SectionKind.ChartAppearance, controls: { fillMode: true } },
];
const oldSpec = oldSpecWith({
chartAppearance: {
fillMode: DashboardtypesFillModeDTO.gradient,
fillOpacity: 0,
},
});
expect(
buildPluginSpec(withOpacity, { oldSpec }).chartAppearance,
).toStrictEqual({
fillMode: DashboardtypesFillModeDTO.gradient,
fillOpacity: 0,
});
expect(
buildPluginSpec(withoutOpacity, { oldSpec }).chartAppearance,
).toStrictEqual({ fillMode: DashboardtypesFillModeDTO.gradient });
});
it('carries old values over the defaults, gated by the declared controls', () => {
const sections: SectionConfig[] = [
{
@@ -551,6 +729,21 @@ describe('buildPluginSpec', () => {
});
});
it('seeds the full Area default set, filled solid and stacked', () => {
expect(buildPluginSpec(areaSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stack: DashboardtypesStackModeDTO.normal,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.solid,
},
});
});
it('returns an empty spec for List (only switchPanelKind, nothing to seed)', () => {
expect(buildPluginSpec(listSections)).toStrictEqual({});
});

View File

@@ -5,6 +5,7 @@ import {
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesStackModeDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTextAlignDTO,
DashboardtypesTimePreferenceDTO,
@@ -119,6 +120,43 @@ function isEmptySlice(value: object): boolean {
: Object.keys(value).length === 0;
}
/**
* Translates stacking across a Bar↔Area switch rather than dropping it. Area's
* `percent` has no bar equivalent, so it collapses to stacked-on; a stack-mode kind
* with nothing to carry starts on `normal`.
*/
function seedStacking(
controls: SectionControls[SectionKind.Visualization],
old: SectionSpecMap[SectionKind.Visualization] | undefined,
): Pick<
SectionSpecMap[SectionKind.Visualization],
'stack' | 'stackedBarChart'
> {
if (controls.stacking) {
if (old?.stackedBarChart !== undefined) {
return { stackedBarChart: old.stackedBarChart };
}
if (old?.stack !== undefined) {
return { stackedBarChart: old.stack !== DashboardtypesStackModeDTO.none };
}
return {};
}
if (controls.stackMode) {
if (old?.stack !== undefined) {
return { stack: old.stack };
}
if (old?.stackedBarChart !== undefined) {
return {
stack: old.stackedBarChart
? DashboardtypesStackModeDTO.normal
: DashboardtypesStackModeDTO.none,
};
}
return { stack: DashboardtypesStackModeDTO.normal };
}
return {};
}
const SECTION_SEEDS: SectionSeeds = {
[SectionKind.TextLayout]: {
specKey: 'presentation',
@@ -158,10 +196,7 @@ const SECTION_SEEDS: SectionSeeds = {
timePreference:
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
}),
...(controls.stacking &&
old?.stackedBarChart !== undefined && {
stackedBarChart: old.stackedBarChart,
}),
...seedStacking(controls, old),
...(controls.fillSpans &&
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
};
@@ -204,7 +239,8 @@ const SECTION_SEEDS: SectionSeeds = {
const {
lineStyle = DashboardtypesLineStyleDTO.solid,
lineInterpolation = DashboardtypesLineInterpolationDTO.spline,
fillMode = DashboardtypesFillModeDTO.none,
fillMode,
fillOpacity,
showPoints,
spanGaps,
} = oldPluginSpec?.chartAppearance ?? {};
@@ -216,7 +252,16 @@ const SECTION_SEEDS: SectionSeeds = {
appearance.lineInterpolation = lineInterpolation;
}
if (controls.fillMode) {
appearance.fillMode = fillMode;
const carried = fillMode ?? DashboardtypesFillModeDTO.none;
// An always-filled kind's wire enum has no `none`, so the save API would
// reject it. Keyed off the capability, not the kind.
appearance.fillMode =
controls.fillOpacity && carried === DashboardtypesFillModeDTO.none
? DashboardtypesFillModeDTO.solid
: carried;
}
if (controls.fillOpacity && typeof fillOpacity === 'number') {
appearance.fillOpacity = fillOpacity;
}
if (controls.showPoints && showPoints !== undefined) {
appearance.showPoints = showPoints;

View File

@@ -1,4 +1,14 @@
import { resolveSpanGaps } from '../resolvers';
import {
DashboardtypesAreaFillModeDTO,
DashboardtypesStackModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { FillMode, StackMode } from 'lib/uPlotV2/config/types';
import {
resolveAreaFillMode,
resolveSpanGaps,
resolveStackMode,
} from '../resolvers';
describe('resolveSpanGaps', () => {
it('parses a duration string into seconds when thresholding', () => {
@@ -33,3 +43,43 @@ describe('resolveSpanGaps', () => {
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
});
});
describe('resolveAreaFillMode', () => {
it('maps each wire value to its chart fill mode', () => {
expect(resolveAreaFillMode(DashboardtypesAreaFillModeDTO.solid)).toBe(
FillMode.Solid,
);
expect(resolveAreaFillMode(DashboardtypesAreaFillModeDTO.gradient)).toBe(
FillMode.Gradient,
);
});
// Includes a stale `none`, which the area wire enum no longer carries.
it('falls back to solid for a missing or unknown value', () => {
expect(resolveAreaFillMode(undefined)).toBe(FillMode.Solid);
expect(resolveAreaFillMode('none' as DashboardtypesAreaFillModeDTO)).toBe(
FillMode.Solid,
);
});
});
describe('resolveStackMode', () => {
it('maps each wire value to its chart stack mode', () => {
expect(resolveStackMode(DashboardtypesStackModeDTO.none)).toBe(
StackMode.None,
);
expect(resolveStackMode(DashboardtypesStackModeDTO.normal)).toBe(
StackMode.Normal,
);
expect(resolveStackMode(DashboardtypesStackModeDTO.percent)).toBe(
StackMode.Percent,
);
});
it('falls back to none for a missing or unknown value', () => {
expect(resolveStackMode(undefined)).toBe(StackMode.None);
expect(resolveStackMode('stretch' as DashboardtypesStackModeDTO)).toBe(
StackMode.None,
);
});
});

View File

@@ -1,14 +1,17 @@
import {
DashboardtypesAreaFillModeDTO,
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesStackModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
FillMode,
LineInterpolation,
LineStyle,
StackMode,
} from 'lib/uPlotV2/config/types';
/**
@@ -38,6 +41,21 @@ export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
/** Narrower than TimeSeries' — an area panel is always filled, so there is no `none`. */
export const AREA_FILL_MODE_MAP: Record<
DashboardtypesAreaFillModeDTO,
FillMode
> = {
[DashboardtypesAreaFillModeDTO.solid]: FillMode.Solid,
[DashboardtypesAreaFillModeDTO.gradient]: FillMode.Gradient,
};
export const STACK_MODE_MAP: Record<DashboardtypesStackModeDTO, StackMode> = {
[DashboardtypesStackModeDTO.none]: StackMode.None,
[DashboardtypesStackModeDTO.normal]: StackMode.Normal,
[DashboardtypesStackModeDTO.percent]: StackMode.Percent,
};
export const LEGEND_POSITION_MAP: Record<
DashboardtypesLegendPositionDTO,
LegendPosition

View File

@@ -1,13 +1,20 @@
import { rangeUtil } from '@grafana/data';
import {
type DashboardtypesAreaFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
type DashboardtypesSpanGapsDTO,
type DashboardtypesStackModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { FillMode, StackMode } from 'lib/uPlotV2/config/types';
import { LEGEND_POSITION_MAP } from './enumMaps';
import {
AREA_FILL_MODE_MAP,
LEGEND_POSITION_MAP,
STACK_MODE_MAP,
} from './enumMaps';
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
// values, falling back to chart defaults for missing/unknown input.
@@ -65,3 +72,23 @@ export function resolveLegendPosition(
}
return LegendPosition.BOTTOM;
}
/** Missing/unknown falls back to `Solid`; an area panel is never a bare line. */
export function resolveAreaFillMode(
fillMode: DashboardtypesAreaFillModeDTO | undefined,
): FillMode {
if (fillMode && fillMode in AREA_FILL_MODE_MAP) {
return AREA_FILL_MODE_MAP[fillMode];
}
return FillMode.Solid;
}
/** Missing/unknown falls back to `None` — series drawn independently. */
export function resolveStackMode(
stack: DashboardtypesStackModeDTO | undefined,
): StackMode {
if (stack && stack in STACK_MODE_MAP) {
return STACK_MODE_MAP[stack];
}
return StackMode.None;
}

View File

@@ -100,6 +100,7 @@ export function panelTypeToRequestType(
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.AREA:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:

View File

@@ -12,6 +12,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
[PANEL_TYPES.TABLE]: ExplorerViews.TABLE,
[PANEL_TYPES.VALUE]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.BAR]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.AREA]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
// Dashboard-only visualisation; explorers never offer it.

View File

@@ -1467,6 +1467,264 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
}
}
func TestAreaChartPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/AreaChartPanel",
"spec": {}
},
"queries": [{"kind": "time_series", "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, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
assert.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "area fillMode defaults to solid, where the TimeSeries FillMode defaults to none")
assert.Nil(t, spec.ChartAppearance.FillOpacity, "an omitted fillOpacity stays nil so the renderer applies the kind default")
assert.Equal(t, "none", spec.Visualization.Stack.ValueOrDefault(), "expected Stack default none")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
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")
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
outputStr := string(output)
for field, want := range map[string]string{
"fillMode": `"solid"`,
"stack": `"none"`,
"fillOpacity": `null`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
}
func TestAreaChartPanelRoundTrip(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/AreaChartPanel",
"spec": {
"visualization": {"timePreference": "global_time", "fillSpans": false, "stack": "percent"},
"chartAppearance": {"fillMode": "gradient", "fillOpacity": 0.4}
}
},
"queries": [{"kind": "time_series", "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, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
assert.Equal(t, "percent", spec.Visualization.Stack.ValueOrDefault(), "expected stack percent")
assert.Equal(t, "gradient", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected fillMode gradient")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"stack":"percent"`, "expected stack in stored/response JSON")
assert.Contains(t, string(output), `"fillMode":"gradient"`, "expected fillMode in stored/response JSON")
}
func TestAreaChartPanelFillOpacity(t *testing.T) {
tests := []struct {
scenario string
chartAppearance string
expectedFillOpacitySet bool
expectedFillOpacityValue FillOpacity
expectedMarshalledJSON string
}{
{
scenario: "zero is a set value, not an absent one",
chartAppearance: `{"fillOpacity": 0}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 0,
expectedMarshalledJSON: `"fillOpacity":0`,
},
{
scenario: "fully opaque upper bound",
chartAppearance: `{"fillOpacity": 1}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 1,
expectedMarshalledJSON: `"fillOpacity":1`,
},
{
scenario: "typical fractional value",
chartAppearance: `{"fillOpacity": 0.4}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 0.4,
expectedMarshalledJSON: `"fillOpacity":0.4`,
},
{
scenario: "precision beyond one decimal place survives",
chartAppearance: `{"fillOpacity": 0.125}`,
expectedFillOpacitySet: true,
expectedFillOpacityValue: 0.125,
expectedMarshalledJSON: `"fillOpacity":0.125`,
},
{
scenario: "omitted field stays nil so the renderer applies the kind default",
chartAppearance: `{}`,
expectedFillOpacitySet: false,
expectedMarshalledJSON: `"fillOpacity":null`,
},
{
scenario: "explicit null stays nil rather than decoding as zero",
chartAppearance: `{"fillOpacity": null}`,
expectedFillOpacitySet: false,
expectedMarshalledJSON: `"fillOpacity":null`,
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/AreaChartPanel", "spec": {"chartAppearance": ` + test.chartAppearance + `}},
"queries": [{"kind": "time_series", "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, &AreaChartPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*AreaChartPanelSpec)
if !test.expectedFillOpacitySet {
assert.Nil(t, spec.ChartAppearance.FillOpacity, "expected fillOpacity to stay unset")
} else {
require.NotNil(t, spec.ChartAppearance.FillOpacity, "expected fillOpacity to decode as a set value")
assert.Equal(t, test.expectedFillOpacityValue, *spec.ChartAppearance.FillOpacity, "unexpected decoded fillOpacity")
}
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), test.expectedMarshalledJSON, "unexpected fillOpacity in stored/response JSON")
})
}
}
func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown stack mode",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"visualization": {"stack": "stacked"}}`,
expectedErrorSubstring: "stack mode",
},
{
scenario: "unknown area fill mode",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillMode": "striped"}}`,
expectedErrorSubstring: "fill mode",
},
{
scenario: "fill opacity on a 0-100 scale",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 40}}`,
expectedErrorSubstring: "invalid fillOpacity 40: must be between 0 and 1",
},
{
scenario: "negative fill opacity",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": -0.5}}`,
expectedErrorSubstring: "invalid fillOpacity -0.5: must be between 0 and 1",
},
{
scenario: "non-numeric fill opacity",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": "0.4"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "stack on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"visualization": {"stack": "normal"}}`,
expectedErrorSubstring: `unknown field`,
},
{
scenario: "fill opacity on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.4}}`,
expectedErrorSubstring: `unknown field`,
},
{
scenario: "stacked bar chart on an area panel",
panelKind: "signoz/AreaChartPanel",
panelSpec: `{"visualization": {"stackedBarChart": true}}`,
expectedErrorSubstring: `unknown field`,
},
{
scenario: "stack on a bar chart panel",
panelKind: "signoz/BarChartPanel",
panelSpec: `{"visualization": {"stack": "percent"}}`,
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": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],

View File

@@ -30,6 +30,7 @@ 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"),
@@ -61,6 +62,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
return []any{
PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)},
PanelPluginVariant[BarChartPanelSpec]{Kind: string(PanelKindBarChart)},
PanelPluginVariant[AreaChartPanelSpec]{Kind: string(PanelKindAreaChart)},
PanelPluginVariant[NumberPanelSpec]{Kind: string(PanelKindNumber)},
PanelPluginVariant[PieChartPanelSpec]{Kind: string(PanelKindPieChart)},
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
@@ -225,6 +227,7 @@ 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) },
@@ -248,6 +251,7 @@ var (
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},

View File

@@ -188,7 +188,8 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
return nil, err
}
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
// fillGaps lives on the panel visualization; only timeseries, bar chart and
// area chart carry it.
fillGaps := false
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
case *TimeSeriesPanelSpec:
@@ -199,6 +200,10 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
if panelSpec != nil {
fillGaps = panelSpec.Visualization.FillSpans
}
case *AreaChartPanelSpec:
if panelSpec != nil {
fillGaps = panelSpec.Visualization.FillSpans
}
}
return &qb.QueryRangeRequest{

View File

@@ -168,6 +168,7 @@ 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"
@@ -177,7 +178,7 @@ const (
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -209,6 +210,30 @@ type BarChartPanelSpec struct {
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
type AreaChartPanelSpec struct {
Visualization AreaChartVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
ChartAppearance AreaChartAppearance `json:"chartAppearance"`
Axes Axes `json:"axes"`
Legend Legend `json:"legend"`
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
// AreaChartAppearance repeats the line-drawing fields rather than embedding
// TimeSeriesChartAppearance: both carry a `fillMode` under different enums, and
// a duplicated json tag across an embed boundary is resolved by depth, which the
// schema reflector does not model.
type AreaChartAppearance struct {
LineInterpolation LineInterpolation `json:"lineInterpolation"`
ShowPoints bool `json:"showPoints"`
LineStyle LineStyle `json:"lineStyle"`
FillMode AreaFillMode `json:"fillMode"`
// FillOpacity is a pointer so an omitted field resolves to the kind default at
// render time; a plain value would make the Go zero value a transparent fill.
FillOpacity *FillOpacity `json:"fillOpacity"`
SpanGaps SpanGaps `json:"spanGaps"`
}
type NumberPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
@@ -287,6 +312,12 @@ type BarChartVisualization struct {
StackedBarChart bool `json:"stackedBarChart"`
}
type AreaChartVisualization struct {
BasicVisualization
FillSpans bool `json:"fillSpans"`
Stack StackMode `json:"stack"`
}
type PanelFormatting struct {
Unit string `json:"unit"`
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
@@ -647,6 +678,106 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
}
}
type AreaFillMode struct{ valuer.String }
var (
AreaFillModeSolid = AreaFillMode{valuer.NewString("solid")} // default
AreaFillModeGradient = AreaFillMode{valuer.NewString("gradient")}
)
func (AreaFillMode) Enum() []any {
return []any{AreaFillModeSolid, AreaFillModeGradient}
}
func (fm AreaFillMode) ValueOrDefault() string {
if fm.IsZero() {
return AreaFillModeSolid.StringValue()
}
return fm.StringValue()
}
func (fm AreaFillMode) MarshalJSON() ([]byte, error) {
return json.Marshal(fm.ValueOrDefault())
}
func (fm *AreaFillMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
val := AreaFillMode{valuer.NewString(v)}
switch val {
case AreaFillModeSolid, AreaFillModeGradient:
*fm = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
}
}
// StackMode is area-only. Bar stacking stays on BarChartVisualization.StackedBarChart,
// so `percent` is not reachable from a bar panel.
type StackMode struct{ valuer.String }
var (
StackModeNone = StackMode{valuer.NewString("none")} // default
StackModeNormal = StackMode{valuer.NewString("normal")}
StackModePercent = StackMode{valuer.NewString("percent")}
)
func (StackMode) Enum() []any {
return []any{StackModeNone, StackModeNormal, StackModePercent}
}
func (sm StackMode) ValueOrDefault() string {
if sm.IsZero() {
return StackModeNone.StringValue()
}
return sm.StringValue()
}
func (sm StackMode) MarshalJSON() ([]byte, error) {
return json.Marshal(sm.ValueOrDefault())
}
func (sm *StackMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid stack mode: must be a string, one of `none`, `normal`, or `percent`")
}
val := StackMode{valuer.NewString(v)}
switch val {
case StackModeNone, StackModeNormal, StackModePercent:
*sm = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid stack mode %q: must be `none`, `normal`, or `percent`", v)
}
}
// FillOpacity is the alpha of an area fill, in 01 because that is what the
// chart layer consumes directly. Unlike the enums in this section it has no
// ValueOrDefault: 0 is a legitimate value, so the kind default lives at render
// time behind a nil pointer.
type FillOpacity float64
func (FillOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(0).WithMaximum(1)
return nil
}
func (o *FillOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fillOpacity: must be a number between 0 and 1")
}
if v < 0 || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fillOpacity %v: must be between 0 and 1", v)
}
*o = FillOpacity(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."`

View File

@@ -14,6 +14,11 @@ import (
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
// invalid v2 envelopes — run the v4→v5 migration first.
//
// The v1 input shape is closed: nothing writes v1 dashboards any more, so these
// files only ever convert what v1 could already express. Panel kinds and spec
// fields added to v2 from here on need no converter entry — change these files
// only when a v2 type edit breaks the build.
//
// The conversion is split across sibling files by concern:
// - perses_v1_to_v2_tags.go tags
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)