Compare commits

..

2 Commits

Author SHA1 Message Date
aks07
1af45169d5 feat(quick-filters): scroll the filter sidebar and content independently
Adds QuickFiltersLayout, a bounded two-pane layout that renders QuickFilters
in a fixed-width sidebar and the page content in its own OverlayScrollbar,
and moves Traces, LLM Observability, API Monitoring, Exceptions and Meter
onto it. The sidebar is 280px on every page. Logs and Infra are unchanged.
2026-09-19 11:45:20 +05:30
aks07
0c2a874e07 feat(route-tab): scroll tab content inside the pane instead of the page
RouteTab now owns the antd Tabs height chain and wraps each pane in an
OverlayScrollbar, so the tab bar stays put and pages no longer need their
own .ant-tabs overrides. Module pages pass their class to RouteTab instead
of wrapping it in a div, which was the auto-height link that let tall
content grow the page.
2026-09-18 19:40:48 +05:30
96 changed files with 992 additions and 3148 deletions

View File

@@ -384,49 +384,13 @@ components:
required:
- routingKey
type: object
AlertmanagertypesChannelSlackAction:
properties:
confirm:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
name:
type: string
style:
type: string
text:
type: string
type:
type: string
url:
type: string
value:
type: string
required:
- type
- text
type: object
AlertmanagertypesChannelSlackConfig:
properties:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
type: array
apiUrl:
format: password
type: string
channel:
type: string
color:
type: string
fallback:
type: string
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
type: array
footer:
type: string
pretext:
type: string
sendResolved:
nullable: true
type: boolean
@@ -434,37 +398,9 @@ components:
type: string
title:
type: string
titleLink:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelSlackConfirmation:
properties:
dismissText:
type: string
okText:
type: string
text:
type: string
title:
type: string
required:
- text
type: object
AlertmanagertypesChannelSlackField:
properties:
short:
nullable: true
type: boolean
title:
type: string
value:
type: string
required:
- title
- value
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
@@ -3274,53 +3210,6 @@ 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:
@@ -3552,11 +3441,6 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -4019,7 +3903,6 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -4032,7 +3915,6 @@ 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'
@@ -4044,7 +3926,6 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
@@ -4052,18 +3933,6 @@ 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:
@@ -4394,12 +4263,6 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object

View File

@@ -40,73 +40,7 @@ export interface AlertmanagertypesChannelDTO {
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfirmationDTO {
/**
* @type string
*/
dismissText?: string;
/**
* @type string
*/
okText?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelSlackActionDTO {
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
style?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
type: string;
/**
* @type string
*/
url?: string;
/**
* @type string
*/
value?: string;
}
export interface AlertmanagertypesChannelSlackFieldDTO {
/**
* @type boolean,null
*/
short?: boolean | null;
/**
* @type string
*/
title: string;
/**
* @type string
*/
value: string;
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
/**
* @type string
* @format password
@@ -116,26 +50,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
channel?: string;
/**
* @type string
*/
color?: string;
/**
* @type string
*/
fallback?: string;
/**
* @type array
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
/**
* @type string
*/
footer?: string;
/**
* @type string
*/
pretext?: string;
/**
* @type boolean,null
*/
@@ -148,10 +62,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
title?: string;
/**
* @type string
*/
titleLink?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
@@ -4099,52 +4009,6 @@ 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
@@ -4222,11 +4086,6 @@ 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',
@@ -4239,27 +4098,6 @@ 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
@@ -4957,6 +4795,29 @@ 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;
@@ -5009,18 +4870,6 @@ 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',
}
@@ -5226,7 +5075,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
@@ -6151,7 +5999,6 @@ 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

@@ -1,15 +1,21 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig = GetFieldsKeysParams;
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldValuesConfig = GetFieldsValuesParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,

View File

@@ -2,6 +2,8 @@
display: flex;
flex-direction: row;
position: relative;
flex: 1;
min-height: 0;
.quick-filters-settings-container {
flex: 0 0 0;

View File

@@ -0,0 +1,33 @@
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
// only hand height down; each pane below owns its own scroll.
.layout {
display: flex;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
overflow: visible;
z-index: 2;
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}

View File

@@ -0,0 +1,59 @@
import { ComponentProps, ReactNode } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { PartialOptions } from 'overlayscrollbars';
import QuickFilters from '../QuickFilters';
import styles from './QuickFiltersLayout.module.scss';
const CONTENT_SCROLLBAR_OPTIONS: PartialOptions = {
overflow: { x: 'hidden' },
};
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
typeof QuickFilters,
ComponentProps<typeof QuickFilters>
>;
export interface QuickFiltersLayoutProps {
quickFilterProps: QuickFiltersElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
testId?: string;
children: ReactNode;
}
function QuickFiltersLayout({
quickFilterProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showFilters && (
<aside
className={styles.filters}
data-testid="quick-filters-layout-filters"
>
<QuickFilters {...quickFilterProps} />
</aside>
)}
<section
className={cx(styles.content, contentClassName)}
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar options={CONTENT_SCROLLBAR_OPTIONS}>
<div>{children}</div>
</OverlayScrollbar>
</section>
</div>
);
}
export default QuickFiltersLayout;

View File

@@ -0,0 +1,79 @@
import { render, screen } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
filters: 'filters',
content: 'content',
},
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
),
}));
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
describe('QuickFiltersLayout', () => {
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
QuickFiltersSource.TRACES_EXPLORER,
);
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="page-root"
contentClassName="page-content"
testId="page"
>
<div>content</div>
</QuickFiltersLayout>,
);
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'page-content',
);
});
});

View File

@@ -0,0 +1,38 @@
// Hands the parent's height down to the active pane and lets the pane scroll
// its own content, so TopNav and the tab bar stay put. Child combinators only
// (nested Tabs must not be caught).
.routeTab {
flex: 1;
min-height: 0;
}
.routeTab > :global(.ant-tabs-content-holder) {
display: flex;
flex-direction: column;
}
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.routeTab
> :global(.ant-tabs-content-holder)
> :global(.ant-tabs-content)
> :global(.ant-tabs-tabpane-active) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.routeTab
> :global(.ant-tabs-content-holder)
> :global(.ant-tabs-content)
> :global(.ant-tabs-tabpane-active)
> :global(.overlay-scrollbar) {
flex: 1;
min-height: 0;
}

View File

@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
import RouteTab from './index';
import { RouteTabProps } from './types';
jest.mock('./RouteTab.module.scss', () => ({
__esModule: true,
default: { routeTab: 'routeTab' },
}));
function DummyComponent1(): JSX.Element {
return <div>Dummy Component 1</div>;
}
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
expect(history.location.pathname).toBe('/tab2');
});
it('applies the layout class alongside a custom className', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab
history={history}
routes={testRoutes}
activeKey="Tab1"
className="custom-tabs"
/>
</Router>,
);
expect(container.querySelector('.ant-tabs')).toHaveClass(
'routeTab',
'custom-tabs',
);
});
it('renders the active tab content inside an overlay scrollbar', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
expect(
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
).toHaveTextContent('Dummy Component 1');
});
it('calls onChangeHandler on tab change', () => {
const onChangeHandler = jest.fn();
const history = createMemoryHistory();

View File

@@ -5,20 +5,32 @@ import {
useParams,
} from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { RouteTabProps } from './types';
import styles from './RouteTab.module.scss';
interface Params {
[key: string]: string;
}
/**
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
* a plain block wrapper the scroller is inert and the page scrolls as usual.
* Pane content that needs a bounded box must size itself with `height: 100%`
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
*/
function RouteTab({
routes,
activeKey,
onChangeHandler,
history,
showRightSection,
className,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
@@ -50,11 +62,16 @@ function RouteTab({
label: name,
key,
tabKey: route,
children: <Component />,
children: (
<OverlayScrollbar>
<Component />
</OverlayScrollbar>
),
}));
return (
<Tabs
className={cx(styles.routeTab, className)}
onChange={onChange}
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}

View File

@@ -29,7 +29,6 @@ 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

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

View File

@@ -1,23 +1,15 @@
.api-monitoring-page {
display: flex;
height: 100%;
.api-monitoring-explorer {
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
.api-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 18px;
}
font-size: 14px;
line-height: 18px;
}
.api-module-right-section {
@@ -161,16 +153,6 @@
}
}
}
&.filter-visible {
.api-quick-filter-left-section {
width: 260px;
}
.api-module-right-section {
width: calc(100% - 260px);
}
}
}
.no-filtered-domains-message-container {

View File

@@ -1,8 +1,7 @@
import { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
signal={SignalType.API_MONITORING}
showFilterCollapse={false}
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
useFieldApis={quickFilterFieldApis}
/>
</section>
<QuickFiltersLayout
className="api-monitoring-explorer"
showFilters
quickFilterProps={{
className: 'qf-api-monitoring',
source: QuickFiltersSource.API_MONITORING,
signal: SignalType.API_MONITORING,
showFilterCollapse: false,
showQueryName: false,
handleFilterVisibilityChange: (): void => {},
useFieldApis: quickFilterFieldApis,
}}
>
<DomainList />
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -65,8 +65,6 @@
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
@@ -75,32 +73,8 @@
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -2,18 +2,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueryAIWithType } from 'constants/queryBuilder';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -22,6 +26,7 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -30,7 +35,7 @@ import {
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from './aiActions';
} from 'pages/TracesExplorer/aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -38,10 +43,12 @@ import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
@@ -51,6 +58,7 @@ import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
@@ -62,12 +70,20 @@ function Explorer(): JSX.Element {
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -98,13 +114,15 @@ function Explorer(): JSX.Element {
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
DEFAULT_PANEL_TYPE,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -119,7 +137,7 @@ function Explorer(): JSX.Element {
},
[handleExplorerTabChange, handleSetConfig],
);
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
@@ -159,6 +177,59 @@ function Explorer(): JSX.Element {
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
@@ -180,25 +251,20 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
<QuickFiltersLayout
className="trace-explorer-page"
data-testid="llm-observability-explorer"
testId="llm-observability-explorer"
showFilters={isOpen}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.TRACES_EXPLORER,
signal: SignalType.TRACES,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
}}
>
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<div className="trace-explorer">
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
@@ -281,8 +347,16 @@ function Explorer(): JSX.Element {
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -12,17 +12,25 @@ import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -34,7 +42,6 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
@@ -72,6 +79,14 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -83,6 +98,19 @@ function ListView({
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -92,6 +120,7 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
@@ -99,6 +128,7 @@ function ListView({
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
@@ -120,7 +150,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns: options?.selectColumns,
},
},
ENTITY_VERSION_V5,
@@ -128,7 +158,10 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
},
);
@@ -153,20 +186,28 @@ function ListView({
[queryTableDataResult],
);
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
[],
);
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
@@ -194,9 +235,15 @@ function ListView({
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
@@ -204,8 +251,6 @@ function ListView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -213,6 +258,8 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);

View File

@@ -1,41 +1,19 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;
export const defaultSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'name',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
},
{
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
signal: 'traces',
fieldContext: 'span',
},
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,8 +1,47 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
@@ -21,6 +60,95 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.

View File

@@ -4,10 +4,8 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const isRawQuery = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,

View File

@@ -107,7 +107,7 @@ function TableView({
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="ai-traces-table"
fileName="traces-table"
/>
</div>
)}

View File

@@ -126,7 +126,6 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
exportFileName="ai-traces-timeseries"
/>
</div>
);

View File

@@ -55,9 +55,6 @@ function TracesTable({
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
// Rows can land before the field keys, and mounting then renders a partial column set.
const canMountTable = !isError && !isLoading && data.length !== 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
@@ -86,7 +83,7 @@ function TracesTable({
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{canMountTable && (
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}

View File

@@ -1,72 +0,0 @@
import { useState } from 'react';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { buildTraceViewColumns } from '../../TracesView/configs';
import TracesTable from '../TracesTable';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);
return (
<>
<button type="button" onClick={(): void => setColumnsReady(true)}>
columns-ready
</button>
<TracesTable
data={ROWS}
columns={columnsReady ? COLUMNS : []}
columnStorageKey={STORAGE_KEY}
respectColumnOrder
panelType="TRACE"
getRowHref={(): string => '/trace/abc'}
isLoading={!columnsReady}
isFetching={false}
isError={false}
error={null}
isFilterApplied={false}
/>
</>
);
}
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
describe('TracesTable column-init race', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
it('does not persist empty defaults when rows land before columns', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RaceHarness />);
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -1,13 +1,6 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
export const TIMESTAMP_FIELD_NAMES = new Set([
'timestamp',
'start_time',
'end_time',
'last_activity_time',
]);
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
@@ -20,12 +13,6 @@ export const STATUS_FIELD_NAMES = new Set([
'http.response.status_code',
]);
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
export const DURATION_FIELD_NAMES = new Set([
'durationNano',
'duration_nano',
'trace_duration_nano',
'max_llm_duration_nano',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -67,7 +67,6 @@ function TracesView({
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const {
@@ -169,22 +168,9 @@ function TracesView({
setOrderBy(value);
}, []);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
return (
@@ -221,12 +207,8 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}

View File

@@ -1,190 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
const OPTIONS_TRIGGER = 'options_menu.options';
const ROWS = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'checkout',
root_span_name: 'HTTP GET',
trace_duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
];
const mockRows = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'trace',
data: { results: [{ queryName: 'A', rows: ROWS }] },
},
}),
),
),
);
};
const mockFieldKeys = (names: string[]): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
),
),
);
};
const mockFieldKeysFailure = (): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView column persistence', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
mockRows();
});
afterEach(() => {
server.resetHandlers();
});
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('persists nothing when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
});
});

View File

@@ -149,62 +149,6 @@ describe('useTraceViewColumns', () => {
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();

View File

@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',

View File

@@ -35,17 +35,11 @@ interface UseTraceViewColumns {
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
@@ -66,10 +60,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
if (isFetched) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isSuccess, columns]);
}, [isFetched, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
@@ -89,10 +83,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
@@ -106,7 +96,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns, isSuccess],
[columns],
);
return {
@@ -115,6 +105,5 @@ export function useTraceViewColumns(): UseTraceViewColumns {
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -1,17 +1,7 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
list: {
name: 'list',
label: 'List',
@@ -25,6 +15,13 @@ export const TOOLBAR_VIEWS = {
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',

View File

@@ -1,5 +1,6 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
@@ -30,3 +31,31 @@ export const getListViewQuery = (
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -1,21 +1,19 @@
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
import './ToolbarActions.styles.scss';
interface ToolbarViewItem {
name: string;
key: string;
show?: boolean;
disabled?: boolean;
}
interface LeftToolbarActionsProps {
items: Record<string, ToolbarViewItem>;
items: any;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
@@ -31,6 +29,8 @@ export default function LeftToolbarActions({
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
@@ -41,34 +41,91 @@ export default function LeftToolbarActions({
</Button>
</Tooltip>
)}
{/* Buttons render in the order the caller declares its views. */}
<div className="left-toolbar-query-actions">
{Object.values(items).map((item) => {
const config = TOOLBAR_VIEW_CONFIG[item?.key];
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
if (!item?.show || !config) {
return null;
}
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
const { icon: Icon, label, className, testId } = config;
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
return (
<Tooltip key={item.key} title={label}>
<Button
disabled={item.disabled}
className={cx(
className,
'explorer-view-option',
selectedView === item.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
>
<Icon size={14} data-testid={testId} />
{label}
</Button>
</Tooltip>
);
})}
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
</div>
</div>
);

View File

@@ -1,47 +0,0 @@
import {
Atom,
Binoculars,
SquareMousePointer,
Terminal,
} from '@signozhq/icons';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
export interface ToolbarViewConfig {
icon: typeof Atom;
label: string;
className: string;
testId: string;
}
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
[ExplorerViews.LIST]: {
icon: SquareMousePointer,
label: 'List View',
className: 'list-view-tab',
testId: 'search-view',
},
[ExplorerViews.TRACE]: {
icon: SquareMousePointer,
label: 'Trace View',
className: 'trace-view-tab',
testId: 'trace-view',
},
[ExplorerViews.TIMESERIES]: {
icon: Atom,
label: 'Time Series',
className: 'timeseries-view-tab',
testId: 'query-builder-view',
},
[ExplorerViews.CLICKHOUSE]: {
icon: Terminal,
label: 'Clickhouse',
className: 'clickhouse-view-tab',
testId: 'clickhouse-view',
},
[ExplorerViews.TABLE]: {
icon: Binoculars,
label: 'Table',
className: 'table-view-tab',
testId: 'query-builder-view-v2',
},
};

View File

@@ -1,18 +1,7 @@
.meter-explorer-container {
display: flex;
flex-direction: row;
.meter-explorer-quick-filters-section {
width: 280px;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
}
.meter-explorer-content-section {
width: 100%;
// Clearance for the fixed ExplorerOptions bar.
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
@@ -83,14 +72,6 @@
}
}
}
&.quick-filters-open {
.meter-explorer-content-section {
width: calc(100% - 280px);
}
}
padding-bottom: 80px;
}
.dashboards-and-alerts-popover-container {

View File

@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
className={cx('meter-explorer-container', {
'quick-filters-open': showQuickFilters,
})}
<QuickFiltersLayout
className="meter-explorer-container"
showFilters={showQuickFilters}
quickFilterProps={{
className: 'qf-meter-explorer',
source: QuickFiltersSource.METER_EXPLORER,
signal: SignalType.METER_EXPLORER,
showFilterCollapse: true,
showQueryName: false,
handleFilterVisibilityChange: (): void => {
setShowQuickFilters(!showQuickFilters);
},
useFieldApis: quickFilterFieldApis,
}}
>
<div
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
>
<QuickFilters
className="qf-meter-explorer"
source={QuickFiltersSource.METER_EXPLORER}
signal={SignalType.METER_EXPLORER}
showFilterCollapse
showQueryName={false}
handleFilterVisibilityChange={(): void => {
setShowQuickFilters(!showQuickFilters);
}}
useFieldApis={quickFilterFieldApis}
/>
</div>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">
<div className="explore-header">
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
splitedQueries={splitedQueries}
/>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -64,7 +64,6 @@ function TimeSeriesView({
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
exportFileName,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -271,7 +270,7 @@ function TimeSeriesView({
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
@@ -340,7 +339,6 @@ interface TimeSeriesViewProps {
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
exportFileName?: string;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
@@ -353,7 +351,6 @@ TimeSeriesView.defaultProps = {
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
exportFileName: undefined,
};
export default TimeSeriesView;

View File

@@ -27,7 +27,6 @@ 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,6 +19,5 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,
[PANEL_TYPES.AREA]: TimeSeriesPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
};

View File

@@ -3,7 +3,6 @@ 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 {
@@ -59,8 +58,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}: {
resolvedLineColor: string;
}): Partial<Series> {
const { lineWidth, lineStyle, lineCap, fillColor, fillMode, fillOpacity } =
this.props;
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? DEFAULT_LINE_WIDTH,
@@ -88,17 +86,11 @@ 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}${toAlphaHex(resolvedOpacity)}`;
lineConfig.fill = `${finalFillColor}70`;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(
self,
finalFillColor,
'rgba(0, 0, 0, 0)',
resolvedOpacity,
);
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
}
}

View File

@@ -3,7 +3,7 @@ import uPlot from 'uplot';
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
import type { SeriesProps } from '../types';
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = (
@@ -362,40 +362,4 @@ 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,8 +222,6 @@ 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

@@ -1,43 +0,0 @@
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

@@ -1,27 +0,0 @@
/**
* `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;
/** Gradient stop offsets, top to bottom of the plot area. */
export const GRADIENT_START_STOP = 0;
export const GRADIENT_MID_STOP = 0.6;
export const GRADIENT_END_STOP = 1;
/** 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,19 +1,9 @@
import uPlot from 'uplot';
import {
DEFAULT_FILL_OPACITY,
GRADIENT_END_STOP,
GRADIENT_MID_STOP,
GRADIENT_MID_STOP_RATIO,
GRADIENT_START_STOP,
toAlphaHex,
} from './fillOpacity';
export function generateGradientFill(
uPlotInstance: uPlot,
startColor: string,
endColor: string,
opacity: number = DEFAULT_FILL_OPACITY,
): CanvasGradient {
const g = uPlotInstance.ctx.createLinearGradient(
0,
@@ -21,11 +11,8 @@ export function generateGradientFill(
0,
uPlotInstance.bbox.height,
);
g.addColorStop(GRADIENT_START_STOP, `${startColor}${toAlphaHex(opacity)}`);
g.addColorStop(
GRADIENT_MID_STOP,
`${startColor}${toAlphaHex(opacity * GRADIENT_MID_STOP_RATIO)}`,
);
g.addColorStop(GRADIENT_END_STOP, endColor);
g.addColorStop(0, `${startColor}70`);
g.addColorStop(0.6, `${startColor}40`);
g.addColorStop(1, endColor);
return g;
}

View File

@@ -137,15 +137,6 @@ 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,11 +42,45 @@ 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).
@@ -59,7 +93,14 @@ function buildStackedSeries({
mode,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const columnTotals = Array(pointCount).fill(0) as number[];
const cumulativeSums = Array(pointCount).fill(0) as number[];
const contributionOf = contributionForMode({
data,
valueSeriesCount,
pointCount,
omit,
mode,
});
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
@@ -69,27 +110,14 @@ function buildStackedSeries({
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (columnTotals[pointIndex] += numericValue);
return (cumulativeSums[pointIndex] += contributionOf(
numericValue,
pointIndex,
));
});
}
}
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

@@ -1,11 +1,4 @@
.all-errors-page {
display: flex;
height: 100%;
.all-errors-quick-filter-section {
width: 0%;
flex-shrink: 0;
}
.all-errors-right-section {
.right-toolbar-actions-container {
display: flex;
@@ -18,14 +11,4 @@
.ant-tabs {
margin: 0 8px;
}
&.filter-visible {
.all-errors-quick-filter-section {
width: 260px;
}
.all-errors-right-section {
width: calc(100% - 260px);
}
}
}

View File

@@ -5,13 +5,11 @@ import { Filter } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
@@ -59,63 +57,52 @@ function AllErrors(): JSX.Element {
const quickFilterFieldApis = useSignalFieldApis();
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<section className={cx('all-errors-quick-filter-section')}>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}
<section
className={cx(
'all-errors-right-section',
showFilters ? 'filter-visible' : '',
)}
>
<TypicalOverlayScrollbar>
<>
<Toolbar
showAutoRefresh={false}
leftActions={
!showFilters ? (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size="md" />
</Button>
</Tooltip>
) : undefined
}
rightActions={
<div className="right-toolbar-actions-container">
<RightToolbarActions
onStageRunQuery={handleRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
}
<QuickFiltersLayout
className="all-errors-page"
contentClassName="all-errors-right-section"
showFilters={showFilters}
quickFilterProps={{
className: 'qf-exceptions',
source: QuickFiltersSource.EXCEPTIONS,
signal: SignalType.EXCEPTIONS,
handleFilterVisibilityChange,
useFieldApis: quickFilterFieldApis,
}}
>
<Toolbar
showAutoRefresh={false}
leftActions={
!showFilters ? (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size="md" />
</Button>
</Tooltip>
) : undefined
}
rightActions={
<div className="right-toolbar-actions-container">
<RightToolbarActions
onStageRunQuery={handleRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
<ResourceAttributesFilterV2 />
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</>
</TypicalOverlayScrollbar>
</section>
</div>
</div>
}
/>
<ResourceAttributesFilterV2 />
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</QuickFiltersLayout>
);
}

View File

@@ -1,11 +1,4 @@
.api-monitoring-page {
flex: 1;
display: flex;
.ant-tabs {
flex: 1;
}
.ant-tabs-nav {
padding: 0 16px;
margin-bottom: 0px;
@@ -15,22 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -13,9 +13,12 @@ function ApiMonitoringPage(): JSX.Element {
const routes: TabRoutes[] = [Explorer];
return (
<div className="api-monitoring-page">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="api-monitoring-page"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -1,23 +0,0 @@
.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

@@ -1,47 +0,0 @@
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,15 +94,21 @@ export const SECTION_REGISTRY: {
},
[SectionKind.ChartAppearance]: {
Component: ChartAppearanceSection,
get: (spec): PanelChartAppearanceSlice | undefined =>
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
spec,
'chartAppearance',
),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
},
[SectionKind.Visualization]: {
Component: VisualizationSection,
get: (spec): PanelVisualizationSlice | undefined =>
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
spec,
'visualization',
),
update: (spec, visualization): PanelSpec =>
updatePluginSlice(spec, 'visualization', visualization),
},

View File

@@ -9,11 +9,8 @@ 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';
@@ -75,21 +72,10 @@ 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, fill opacity, point markers, and the connect-null-gaps
* threshold. Each control is gated by its `controls` flag.
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
* control is gated by its `controls` flag.
*/
function ChartAppearanceSection({
value,
@@ -138,9 +124,7 @@ function ChartAppearanceSection({
<ConfigSegmented
testId="panel-editor-v2-fill-mode"
value={value?.fillMode}
items={
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
}
items={FILL_MODE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
@@ -148,22 +132,6 @@ 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,7 +5,6 @@ import {
DashboardtypesLineStyleDTO,
type DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DEFAULT_FILL_OPACITY } from 'lib/uPlotV2/utils/fillOpacity';
import ChartAppearanceSection from '../ChartAppearanceSection';
@@ -105,66 +104,6 @@ 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,17 +1,14 @@
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';
@@ -24,10 +21,9 @@ 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),
* 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.
* 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.
*/
function VisualizationSection({
value,
@@ -81,20 +77,6 @@ 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,9 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
DashboardtypesStackModeDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import VisualizationSection from '../VisualizationSection';
@@ -118,43 +115,6 @@ 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

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

View File

@@ -27,7 +27,6 @@ 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],
@@ -40,7 +39,6 @@ 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,13 +70,6 @@ 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

@@ -1,233 +0,0 @@
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

@@ -1,48 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,160 +0,0 @@
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,4 +1,3 @@
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';
@@ -22,7 +21,6 @@ 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,11 +28,6 @@ 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,7 +18,6 @@ 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,23 +1,18 @@
import type {
DashboardtypesLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesFillModeDTO,
DashboardtypesFillOpacityDTO,
DashboardtypesHeaderOptionsDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesSpanGapsDTO,
DashboardtypesStackModeDTO,
DashboardtypesTableFormattingDTO,
DashboardtypesTableThresholdDTO,
DashboardtypesTextPresentationDTO,
DashboardtypesThresholdWithLabelDTO,
DashboardtypesTimePreferenceDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { LegendSeriesResolver } from '../utils/legendSeries';
@@ -92,36 +87,15 @@ export type AnyThreshold =
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
/** Superset spanning every kind's chart-appearance DTO. */
export interface PanelChartAppearanceSlice {
lineStyle?: DashboardtypesLineStyleDTO;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
/** Area's wire enum is a nominally distinct subset with the same members. */
fillMode?: DashboardtypesFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO;
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
/** Superset spanning every kind's visualization DTO. */
export interface PanelVisualizationSlice {
timePreference?: DashboardtypesTimePreferenceDTO;
/** Bar stacking; a kind declares this or `stack`, never both. */
stackedBarChart?: boolean;
/** Area stacking. */
stack?: DashboardtypesStackModeDTO;
fillSpans?: boolean;
}
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]: PanelChartAppearanceSlice; // spec.plugin.spec.chartAppearance
[SectionKind.ChartAppearance]: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
[SectionKind.Buckets]: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
// spec.plugin.spec.visualization — typed as the superset of every kind's shape;
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
// the `controls` bag gates which fields each kind writes.
[SectionKind.Visualization]: PanelVisualizationSlice;
[SectionKind.Visualization]: DashboardtypesBarChartVisualizationDTO;
[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)
@@ -150,11 +124,6 @@ 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;
};
@@ -164,13 +133,12 @@ export interface SectionControls {
mergeQueries?: boolean;
};
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
// away from any panel); stacking → stackedBarChart (Bar); stackMode → stack
// (Area); fillSpans → fill gaps with 0 (TimeSeries / Area).
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
// 0 (TimeSeries).
[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,14 +5,12 @@ 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 {
@@ -180,89 +178,6 @@ 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[] = [
{
@@ -349,99 +264,6 @@ 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[] = [
{
@@ -729,21 +551,6 @@ 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,7 +5,6 @@ import {
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesStackModeDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTextAlignDTO,
DashboardtypesTimePreferenceDTO,
@@ -120,43 +119,6 @@ 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 translateStackingForKind(
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',
@@ -196,7 +158,10 @@ const SECTION_SEEDS: SectionSeeds = {
timePreference:
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
}),
...translateStackingForKind(controls, old),
...(controls.stacking &&
old?.stackedBarChart !== undefined && {
stackedBarChart: old.stackedBarChart,
}),
...(controls.fillSpans &&
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
};
@@ -239,8 +204,7 @@ const SECTION_SEEDS: SectionSeeds = {
const {
lineStyle = DashboardtypesLineStyleDTO.solid,
lineInterpolation = DashboardtypesLineInterpolationDTO.spline,
fillMode,
fillOpacity,
fillMode = DashboardtypesFillModeDTO.none,
showPoints,
spanGaps,
} = oldPluginSpec?.chartAppearance ?? {};
@@ -252,16 +216,7 @@ const SECTION_SEEDS: SectionSeeds = {
appearance.lineInterpolation = lineInterpolation;
}
if (controls.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;
appearance.fillMode = fillMode;
}
if (controls.showPoints && showPoints !== undefined) {
appearance.showPoints = showPoints;

View File

@@ -1,14 +1,4 @@
import {
DashboardtypesAreaFillModeDTO,
DashboardtypesStackModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { FillMode, StackMode } from 'lib/uPlotV2/config/types';
import {
resolveAreaFillMode,
resolveSpanGaps,
resolveStackMode,
} from '../resolvers';
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('parses a duration string into seconds when thresholding', () => {
@@ -43,43 +33,3 @@ 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,18 +1,14 @@
import {
DashboardtypesAreaFillModeDTO,
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesStackModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import omit from 'lodash-es/omit';
import {
FillMode,
LineInterpolation,
LineStyle,
StackMode,
} from 'lib/uPlotV2/config/types';
/**
@@ -42,18 +38,6 @@ export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
/** An area panel is always filled, so it drops `none`. */
export const AREA_FILL_MODE_MAP: Record<
DashboardtypesAreaFillModeDTO,
FillMode
> = omit(FILL_MODE_MAP, DashboardtypesFillModeDTO.none);
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,20 +1,13 @@
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 {
AREA_FILL_MODE_MAP,
LEGEND_POSITION_MAP,
STACK_MODE_MAP,
} from './enumMaps';
import { LEGEND_POSITION_MAP } from './enumMaps';
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
// values, falling back to chart defaults for missing/unknown input.
@@ -72,23 +65,3 @@ 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,7 +100,6 @@ 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

@@ -1,13 +1,4 @@
.infra-monitoring-module-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-nav {
padding: 0 8px;
margin-bottom: 0px;
@@ -17,22 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -13,8 +13,11 @@ export default function InfrastructureMonitoringPage(): JSX.Element {
const routes: TabRoutes[] = [Hosts, Kubernetes];
return (
<div className="infra-monitoring-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="infra-monitoring-module-container"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -1,16 +1,4 @@
.logs-module-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.ant-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.ant-tabs-nav {
padding: 0 16px;
margin-bottom: 0px;
@@ -20,25 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
min-height: 0;
.ant-tabs-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -13,8 +13,11 @@ export default function LogsModulePage(): JSX.Element {
const routes: TabRoutes[] = [logsExplorer, logsPipelines, logSaveView];
return (
<div className="logs-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="logs-module-container"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -1,13 +1,4 @@
.messaging-queues-module-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-nav {
padding: 0 8px;
margin-bottom: 0px;
@@ -17,22 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -68,8 +68,11 @@ export default function MessagingQueuesMainPage(): JSX.Element {
];
return (
<div className="messaging-queues-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="messaging-queues-module-container"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -14,14 +14,13 @@ function MeterExplorerPage(): JSX.Element {
const routes: TabRoutes[] = [Meter, Explorer, Views];
return (
<div className="meter-explorer-page">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
defaultActiveKey={ROUTES.METER}
/>
</div>
<RouteTab
className="meter-explorer-page"
routes={routes}
activeKey={pathname}
history={history}
defaultActiveKey={ROUTES.METER}
/>
);
}

View File

@@ -1,13 +1,4 @@
.metrics-explorer-page {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-nav {
padding-left: 16px;
margin-bottom: 0px;
@@ -18,20 +9,7 @@
}
.ant-tabs-content-holder {
display: flex;
padding: 16px;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {

View File

@@ -42,9 +42,12 @@ function MetricsExplorerPage(): JSX.Element {
useShareBuilderUrl({ defaultValue: defaultQuery });
return (
<div className="metrics-explorer-page">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="metrics-explorer-page"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -65,8 +65,6 @@
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
@@ -75,32 +73,8 @@
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -261,23 +259,20 @@ function TracesExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="trace-explorer-page">
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
useFieldApis={quickFilterFieldApis}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<QuickFiltersLayout
className="trace-explorer-page"
showFilters={isOpen}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.TRACES_EXPLORER,
signal: SignalType.TRACES,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
useFieldApis: quickFilterFieldApis,
}}
>
<div className="trace-explorer">
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
@@ -369,7 +364,7 @@ function TracesExplorer(): JSX.Element {
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -25,16 +25,15 @@ function TracesModulePage(): JSX.Element {
};
return (
<div className="traces-module-container">
<RouteTab
routes={routes}
activeKey={
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
onChangeHandler={handleTabChange}
/>
</div>
<RouteTab
className="traces-module-container"
routes={routes}
activeKey={
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
onChangeHandler={handleTabChange}
/>
);
}

View File

@@ -12,7 +12,6 @@ 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

@@ -45,27 +45,11 @@ func TestPostableChannelValidate(t *testing.T) {
postable PostableNotificationChannel
}{
{
description: "webhook basic auth combined with bearer token",
description: "webhook password without username",
postable: PostableNotificationChannel{
Name: "hook",
DisplayName: "hook",
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &ChannelWebhookConfig{URL: "https://a", Password: "p", BearerToken: "t"}},
},
},
{
description: "slack field without a value",
postable: PostableNotificationChannel{
Name: "slack",
DisplayName: "slack",
Config: ChannelConfig{Kind: ChannelKindSlack, Spec: &ChannelSlackConfig{APIURL: "https://a", Fields: []ChannelSlackField{{Title: "Severity"}}}},
},
},
{
description: "slack action with neither url nor name",
postable: PostableNotificationChannel{
Name: "slack",
DisplayName: "slack",
Config: ChannelConfig{Kind: ChannelKindSlack, Spec: &ChannelSlackConfig{APIURL: "https://a", Actions: []ChannelSlackAction{{Type: "button", Text: "Open"}}}},
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &ChannelWebhookConfig{URL: "https://a", Password: "p"}},
},
},
{

View File

@@ -211,38 +211,6 @@ type ChannelSlackConfig struct {
Channel string `json:"channel"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
Color valuer.UnsetOrNonEmptyString `json:"color"`
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
Fields []ChannelSlackField `json:"fields,omitempty"`
Actions []ChannelSlackAction `json:"actions,omitempty"`
}
type ChannelSlackField struct {
Title string `json:"title" required:"true"`
Value string `json:"value" required:"true"`
Short *bool `json:"short,omitempty"`
}
// ChannelSlackAction is a link button when URL is set, otherwise a message
// button that needs Name. Upstream clears whichever side is not in use.
type ChannelSlackAction struct {
Type string `json:"type" required:"true"`
Text string `json:"text" required:"true"`
URL string `json:"url"`
Style string `json:"style"`
Name string `json:"name"`
Value string `json:"value"`
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
}
type ChannelSlackConfirmation struct {
Text string `json:"text" required:"true"`
Title string `json:"title"`
OkText string `json:"okText"`
DismissText string `json:"dismissText"`
}
func (c ChannelSlackConfig) Validate() error {
@@ -250,24 +218,6 @@ func (c ChannelSlackConfig) Validate() error {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
}
for i, field := range c.Fields {
if field.Title == "" || field.Value == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
}
}
for i, action := range c.Actions {
if action.Type == "" || action.Text == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
}
if action.URL == "" && action.Name == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
}
if action.Confirm != nil && action.Confirm.Text == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
}
}
return nil
}
@@ -285,13 +235,6 @@ func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver
Channel: c.Channel,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
Color: c.Color.StringValue(),
TitleLink: c.TitleLink.StringValue(),
Pretext: c.Pretext.StringValue(),
Fallback: c.Fallback.StringValue(),
Footer: c.Footer.StringValue(),
Fields: newUpstreamSlackFields(c.Fields),
Actions: newUpstreamSlackActions(c.Actions),
}},
}}, nil
}
@@ -310,76 +253,9 @@ func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (Channel
Channel: slack.Channel,
Title: valuer.UnsetIfEmpty(slack.Title),
Text: valuer.UnsetIfEmpty(slack.Text),
Color: valuer.UnsetIfEmpty(slack.Color),
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
Footer: valuer.UnsetIfEmpty(slack.Footer),
Fields: newChannelSlackFields(slack.Fields),
Actions: newChannelSlackActions(slack.Actions),
}, nil
}
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
if len(fields) == 0 {
return nil
}
upstream := make([]*config.SlackField, 0, len(fields))
for _, field := range fields {
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
}
return upstream
}
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
if len(upstream) == 0 {
return nil
}
fields := make([]ChannelSlackField, 0, len(upstream))
for _, field := range upstream {
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
}
return fields
}
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
if len(actions) == 0 {
return nil
}
upstream := make([]*config.SlackAction, 0, len(actions))
for _, action := range actions {
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
if action.Confirm != nil {
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
}
upstream = append(upstream, upstreamAction)
}
return upstream
}
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
if len(upstream) == 0 {
return nil
}
actions := make([]ChannelSlackAction, 0, len(upstream))
for _, upstreamAction := range upstream {
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
if upstreamAction.ConfirmField != nil {
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
}
actions = append(actions, action)
}
return actions
}
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
// credentials and TLS settings come from the deployment's global config, so a
// channel can only choose recipients and body.
@@ -433,8 +309,7 @@ func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpe
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
// overloaded onto one password field, where an empty username meant the password
// was really a bearer token. Username or Password may be set without the other,
// as upstream allows, but not together with BearerToken.
// was really a bearer token.
type ChannelWebhookConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true" format:"password"`
@@ -454,6 +329,10 @@ func (c ChannelWebhookConfig) Validate() error {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
}
if usesBasicAuth && (c.Username == "" || c.Password == "") {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.username and config.spec.password must both be set for basic auth")
}
return nil
}
@@ -467,7 +346,7 @@ func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiv
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
// them as false and read back as a config ChannelWebhookConfig cannot represent.
switch {
case c.Username != "" || c.Password != "":
case c.Username != "":
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.BasicAuth = &commoncfg.BasicAuth{
Username: c.Username,

View File

@@ -21,7 +21,6 @@ import (
// mutually exclusive and so cannot all be set at once.
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
sendResolved := true
short := true
testCases := []struct {
description string
@@ -38,16 +37,6 @@ func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
Channel: "#alerts",
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
Color: valuer.MustNewUnsetOrNonEmptyString("#439FE0"),
TitleLink: valuer.MustNewUnsetOrNonEmptyString("{{ .CommonLabels.ruleSource }}"),
Pretext: valuer.MustNewUnsetOrNonEmptyString("slack pretext"),
Fallback: valuer.MustNewUnsetOrNonEmptyString("slack fallback"),
Footer: valuer.MustNewUnsetOrNonEmptyString("slack footer"),
Fields: []ChannelSlackField{{Title: "Severity", Value: "{{ .CommonLabels.severity }}", Short: &short}},
Actions: []ChannelSlackAction{
{Type: "button", Text: "Open in SigNoz", URL: "{{ .CommonLabels.ruleSource }}", Style: "primary"},
{Type: "button", Text: "Acknowledge", Name: "ack", Value: "ack", Confirm: &ChannelSlackConfirmation{Text: "Acknowledge this alert?", Title: "Confirm", OkText: "Yes", DismissText: "No"}},
},
},
expectedRoundTrip: &ChannelSlackConfig{
SendResolved: &sendResolved,
@@ -55,16 +44,6 @@ func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
Channel: "#alerts",
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
Color: valuer.MustNewUnsetOrNonEmptyString("#439FE0"),
TitleLink: valuer.MustNewUnsetOrNonEmptyString("{{ .CommonLabels.ruleSource }}"),
Pretext: valuer.MustNewUnsetOrNonEmptyString("slack pretext"),
Fallback: valuer.MustNewUnsetOrNonEmptyString("slack fallback"),
Footer: valuer.MustNewUnsetOrNonEmptyString("slack footer"),
Fields: []ChannelSlackField{{Title: "Severity", Value: "{{ .CommonLabels.severity }}", Short: &short}},
Actions: []ChannelSlackAction{
{Type: "button", Text: "Open in SigNoz", URL: "{{ .CommonLabels.ruleSource }}", Style: "primary"},
{Type: "button", Text: "Acknowledge", Name: "ack", Value: "ack", Confirm: &ChannelSlackConfirmation{Text: "Acknowledge this alert?", Title: "Confirm", OkText: "Yes", DismissText: "No"}},
},
},
},
{

View File

@@ -1467,264 +1467,6 @@ 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,7 +30,6 @@ 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"),
@@ -62,7 +61,6 @@ 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)},
@@ -227,7 +225,6 @@ 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) },
@@ -251,7 +248,6 @@ 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,8 +188,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
return nil, err
}
// fillGaps lives on the panel visualization; only timeseries, bar chart and
// area chart carry it.
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
fillGaps := false
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
case *TimeSeriesPanelSpec:
@@ -200,10 +199,6 @@ 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,7 +168,6 @@ 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"
@@ -178,7 +177,7 @@ const (
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -210,30 +209,6 @@ 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"`
@@ -312,12 +287,6 @@ 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"`
@@ -678,106 +647,6 @@ 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,11 +14,6 @@ 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)

View File

@@ -27,21 +27,6 @@ _PASSWORD = "password123Z$"
"kind,spec,assert_field,assert_value",
[
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "channel", "#alerts", id="slack"),
pytest.param(
"slack",
{
"apiUrl": "https://hooks.slack.test/services/T/B/X",
"channel": "#alerts",
"color": "#439FE0",
"titleLink": "{{ .CommonLabels.ruleSource }}",
"footer": "platform · terraform",
"fields": [{"title": "Severity", "value": "{{ .CommonLabels.severity }}", "short": True}],
"actions": [{"type": "button", "text": "Open in SigNoz", "url": "{{ .CommonLabels.ruleSource }}"}],
},
"fields",
[{"title": "Severity", "value": "{{ .CommonLabels.severity }}", "short": True}],
id="slack-attachment",
),
pytest.param("email", {"to": "oncall@integration.test", "html": "<p>{{ .CommonLabels.alertname }}</p>"}, "to", "oncall@integration.test", id="email"),
pytest.param("webhook", {"url": "https://webhook.test/hook", "username": "bob", "password": "s3cret"}, "username", "bob", id="webhook"),
pytest.param("pagerduty", {"routingKey": "pd-routing-key", "severity": "critical", "class": "db", "description": "{{ .CommonLabels.alertname }}"}, "severity", "critical", id="pagerduty"),
@@ -328,12 +313,9 @@ def test_create_rejects_a_duplicate_display_name(
pytest.param({"name": "telegram-kind", "config": {"kind": "telegram", "spec": {"chatId": 1}}}, id="unmodelled_kind"),
pytest.param({"name": "slack-unknown-field", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#a", "text": "body", "iconEmoji": ":tada:"}}}, id="unknown_spec_field"),
pytest.param({"name": "slack-with-email-spec", "config": {"kind": "slack", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="spec_of_another_kind"),
pytest.param({"name": "slack-field-without-value", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "fields": [{"title": "Severity"}]}}}, id="slack_field_without_value"),
pytest.param({"name": "slack-action-without-text", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "url": "https://signoz.test"}]}}}, id="slack_action_without_text"),
pytest.param({"name": "slack-action-without-target", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "text": "Open"}]}}}, id="slack_action_without_url_or_name"),
pytest.param({"name": "slack-confirm-without-text", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "text": "Ack", "name": "ack", "confirm": {"title": "Sure?"}}]}}}, id="slack_action_confirm_without_text"),
pytest.param({"name": "extra-field", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}, "type": "email"}, id="unknown_envelope_field"),
pytest.param({"name": "webhook-both-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"}}}, id="webhook_basic_auth_with_bearer_token"),
pytest.param({"name": "webhook-half-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u"}}}, id="webhook_basic_auth_without_password"),
# The last three reach the notifier's own validation rather than the
# spec's, so they assert it still surfaces as a 400 through v2.
pytest.param({"name": "jira-server-site", "config": {"kind": "jira", "spec": {"site": "https://jira.acme.com", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body"}}}, id="jira_site_not_jira_cloud"),