Merge branch 'main' into test/e2e/alert_data_insert_fixture

This commit is contained in:
Abhishek Kumar Singh
2026-02-02 15:15:07 +05:30
42 changed files with 3243 additions and 19 deletions

View File

@@ -66,6 +66,17 @@ Read [more](https://signoz.io/metrics-and-dashboards/).
![metrics-n-dashboards-cover](https://github.com/user-attachments/assets/a536fd71-1d2c-4681-aa7e-516d754c47a5)
### LLM Observability
Monitor and debug your LLM applications with comprehensive observability. Track LLM calls, analyze token usage, monitor performance, and gain insights into your AI application's behavior in production.
SigNoz LLM observability helps you understand how your language models are performing, identify issues with prompts and responses, track token usage and costs, and optimize your AI applications for better performance and reliability.
[Get started with LLM Observability →](https://signoz.io/docs/llm-observability/)
![llm-observability-cover](https://github.com/user-attachments/assets/a6cc0ca3-59df-48f9-9c16-7c843fccff96)
### Alerts
Use alerts in SigNoz to get notified when anything unusual happens in your application. You can set alerts on any type of telemetry signal (logs, metrics, traces), create thresholds and set up a notification channel to get notified. Advanced features like alert history and anomaly detection can help you create smarter alerts.

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.110.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.110.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.110.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.110.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -105,6 +105,7 @@
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
"i18next-http-backend": "^1.3.2",
"immer": "11.1.3",
"jest": "^27.5.1",
"js-base64": "^3.7.2",
"less": "^4.1.2",

View File

@@ -0,0 +1,15 @@
/**
* Represents the visibility state of a single series in a graph
*/
export interface SeriesVisibilityItem {
label: string;
show: boolean;
}
/**
* Represents the stored visibility state for a widget/graph
*/
export interface GraphVisibilityState {
name: string;
dataIndex: SeriesVisibilityItem[];
}

View File

@@ -0,0 +1,74 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
/**
* Retrieves the visibility map for a specific widget from localStorage
* @param widgetId - The unique identifier of the widget
* @returns A Map of series labels to their visibility state, or null if not found
*/
export function getStoredSeriesVisibility(
widgetId: string,
): Map<string, boolean> | null {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
if (!storedData) {
return null;
}
const visibilityStates: GraphVisibilityState[] = JSON.parse(storedData);
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState?.dataIndex?.length) {
return null;
}
return new Map(widgetState.dataIndex.map((item) => [item.label, item.show]));
} catch {
// Silently handle parsing errors - fall back to default visibility
return null;
}
}
export function updateSeriesVisibilityToLocalStorage(
widgetId: string,
seriesVisibility: SeriesVisibilityItem[],
): void {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
let visibilityStates: GraphVisibilityState[];
if (!storedData) {
visibilityStates = [
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
visibilityStates = JSON.parse(storedData);
}
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState) {
visibilityStates = [
...visibilityStates,
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
widgetState.dataIndex = seriesVisibility;
}
localStorage.setItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
JSON.stringify(visibilityStates),
);
} catch {
// Silently handle parsing errors - fall back to default visibility
}
}

View File

@@ -15,6 +15,7 @@ import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { DOCS_BASE_URL } from 'constants/app';
import ROUTES from 'constants/routes';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
@@ -148,6 +149,8 @@ function OnboardingAddDataSource(): JSX.Element {
const { org } = useAppContext();
const { data: globalConfig } = useGetGlobalConfig();
const [setupStepItems, setSetupStepItems] = useState(setupStepItemsBase);
const [searchQuery, setSearchQuery] = useState<string>('');
@@ -233,6 +236,16 @@ function OnboardingAddDataSource(): JSX.Element {
urlObj.searchParams.set('environment', selectedEnvironment);
}
const ingestionUrl = globalConfig?.data?.ingestion_url;
if (ingestionUrl) {
const parts = ingestionUrl.split('.');
if (parts?.length > 1 && parts[0]?.includes('ingest')) {
const region = parts[1];
urlObj.searchParams.set('region', region);
}
}
// Step 3: Return the updated URL as a string
const updatedUrl = urlObj.toString();

View File

@@ -1,7 +1,17 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
import {
findByText,
fireEvent,
render,
userEvent,
waitFor,
} from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { pipelineApiResponseMockData } from '../mocks/pipeline';
import PipelineListsView from '../PipelineListsView';
@@ -75,7 +85,20 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
}),
}));
const BASE_URL = ENVIRONMENT.baseURL;
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
describe('PipelinePage container test', () => {
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
it('should render PipelineListsView section', () => {
const { getByText, container } = render(
<PreferenceContextProvider>
@@ -272,6 +295,7 @@ describe('PipelinePage container test', () => {
});
it('should have populated form fields when edit pipeline is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<PreferenceContextProvider>
<PipelineListsView
@@ -301,5 +325,52 @@ describe('PipelinePage container test', () => {
// to have length 2
expect(screen.queryAllByText('source = nginx').length).toBe(2);
server.use(
rest.get(attributeKeysURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [
{
key: 'otelServiceName',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'service.instance.id',
dataType: DataTypes.String,
type: 'resource',
},
{
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
{
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
],
},
}),
),
),
);
// Open Filter input and type to trigger suggestions
const filterSelect = screen.getByTestId('qb-search-select');
const input = within(filterSelect).getByRole('combobox') as HTMLInputElement;
await user.click(input);
await waitFor(() =>
expect(screen.getByText('otelServiceName')).toBeInTheDocument(),
);
const serviceNameOccurences = await screen.findAllByText('service.name');
expect(serviceNameOccurences.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -356,7 +356,10 @@ function QueryBuilderSearch({
// conditional changes here to use a seperate component to render the example queries based on the option group label
const customRendererForLogsExplorer = options.map((option) => (
<Select.Option key={option.label} value={option.value}>
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRendererForLogs
label={option.label}
value={option.value}
@@ -371,6 +374,7 @@ function QueryBuilderSearch({
return (
<div className="query-builder-search-container">
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={popupContainer}
transitionName=""
@@ -488,7 +492,10 @@ function QueryBuilderSearch({
{isLogsExplorerPage
? customRendererForLogsExplorer
: options.map((option) => (
<Select.Option key={option.label} value={option.value}>
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRenderer
label={option.label}
value={option.value}

View File

@@ -0,0 +1,19 @@
import { useSyncExternalStore } from 'react';
import {
dashboardVariablesStore,
IDashboardVariables,
} from '../../providers/Dashboard/store/dashboardVariablesStore';
export const useDashboardVariables = (): {
dashboardVariables: IDashboardVariables;
} => {
const dashboardVariables = useSyncExternalStore(
dashboardVariablesStore.subscribe,
dashboardVariablesStore.getSnapshot,
);
return {
dashboardVariables,
};
};

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/getAll';
import { useDashboardVariables } from './useDashboardVariables';
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType: 'values',
): IDashboardVariable[];
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType?: 'entries',
): [string, IDashboardVariable][];
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType?: 'values' | 'entries',
): IDashboardVariable[] | [string, IDashboardVariable][] {
const { dashboardVariables } = useDashboardVariables();
return useMemo(() => {
const entries = Object.entries(dashboardVariables || {}).filter(
(entry): entry is [string, IDashboardVariable] =>
Boolean(entry[1].name) && entry[1].type === variableType,
);
return returnType === 'values' ? entries.map(([, value]) => value) : entries;
}, [dashboardVariables, variableType, returnType]);
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { Widgets } from 'types/api/dashboard/getAll';
import { useDashboardVariablesByType } from './useDashboardVariablesByType';
/**
* Hook to get a map of dynamic variable IDs to widget IDs that use them.
* This is useful for determining which widgets need to be refreshed when a dynamic variable changes.
*/
export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
const dynamicVariables = useDashboardVariablesByType('DYNAMIC', 'values');
const { selectedDashboard } = useDashboard();
return useMemo(() => {
const widgets =
selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
return createDynamicVariableToWidgetsMap(
dynamicVariables,
widgets as Widgets[],
);
}, [selectedDashboard, dynamicVariables]);
}

View File

@@ -193,7 +193,11 @@ export const useOptions = (
(option, index, self) =>
index ===
self.findIndex(
(o) => o.label === option.label && o.value === option.value, // to remove duplicate & empty options from list
(o) =>
o.label === option.label &&
o.value === option.value &&
(o.type || '') === (option.type || '') &&
(o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType
) && option.value !== '',
) || []
).map((option) => {

View File

@@ -16,13 +16,19 @@ export function useResizeObserver<T extends HTMLElement>(
});
useEffect(() => {
const handleResize = debounce((entries: ResizeObserverEntry[]) => {
const entry = entries[0];
if (entry) {
const { width, height } = entry.contentRect;
setSize({ width, height });
}
}, debounceTime);
const handleResize = debounce(
(entries: ResizeObserverEntry[]) => {
const entry = entries[0];
if (entry) {
const { width, height } = entry.contentRect;
setSize({ width, height });
}
},
debounceTime,
{
leading: true,
},
);
const ro = new ResizeObserver(handleResize);
const referenceNode = ref.current;

View File

@@ -0,0 +1,91 @@
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
max-width: min(400px, 100%);
cursor: pointer;
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-marker {
border-width: 2px;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Tooltip as AntdTooltip } from 'antd';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
const LEGENDS_PER_SET_DEFAULT = 5;
export default function Legend({
position = LegendPosition.BOTTOM,
config,
legendsPerSet = LEGENDS_PER_SET_DEFAULT,
}: LegendProps): JSX.Element {
const {
legendItemsMap,
focusedSeriesIndex,
setFocusedSeriesIndex,
} = useLegendsSync({ config });
const {
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
// Chunk legend items into rows of LEGENDS_PER_ROW items each
const legendRows = useMemo(() => {
const legendItems = Object.values(legendItemsMap);
if (legendsPerSet >= legendItems.length) {
return [legendItems];
}
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
if (i % legendsPerSet === 0) {
acc.push([]);
}
acc[acc.length - 1].push(curr);
return acc;
}, [] as LegendItem[][]);
}, [legendItemsMap, legendsPerSet]);
const renderLegendRow = useCallback(
(rowIndex: number, row: LegendItem[]): JSX.Element => (
<div
key={rowIndex}
className={cx(
'legend-row',
`legend-row-${position.toLowerCase()}`,
legendRows.length === 1 && position === LegendPosition.BOTTOM
? 'legend-single-row'
: '',
)}
>
{row.map((item) => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
))}
</div>
),
[focusedSeriesIndex, position, legendRows],
);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
>
<Virtuoso
style={{
height: '100%',
width: '100%',
}}
data={legendRows}
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
/>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onToggleSeriesVisibility,
} = usePlotContext();
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
}
onFocusSeries(seriesIndex);
};
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
},
[],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -0,0 +1,59 @@
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
padding: 1rem 1rem 0.5rem 1rem;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
.uplot-tooltip-list-container {
height: 100%;
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
}
}
}
.uplot-tooltip-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.uplot-tooltip-item-marker {
border-radius: 50%;
border-width: 2px;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.uplot-tooltip-item-content {
white-space: wrap;
word-break: break-all;
}
}
}

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipContentItem, TooltipProps } from '../types';
import { buildTooltipContent } from './utils';
import './Tooltip.styles.scss';
const TOOLTIP_LIST_MAX_HEIGHT = 330;
const TOOLTIP_ITEM_HEIGHT = 38;
export default function Tooltip({
seriesIndex,
dataIndexes,
uPlotInstance,
timezone,
yAxisUnit = '',
decimalPrecision,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const headerTitle = useMemo(() => {
const data = uPlotInstance.data;
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
return dayjs(data[0][cursorIdx] * 1000)
.tz(timezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIdx: seriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}),
[uPlotInstance, seriesIndex, dataIndexes, yAxisUnit, decimalPrecision],
);
return (
<div
className={cx(
'uplot-tooltip-container',
isDarkMode ? 'darkMode' : 'lightMode',
)}
>
<div className="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
<div
style={{
height: Math.min(
content.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
),
minHeight: 0,
}}
>
{content.length > 0 ? (
<Virtuoso
className="uplot-tooltip-list"
data={content}
defaultItemHeight={TOOLTIP_ITEM_HEIGHT}
itemContent={(_, item): JSX.Element => (
<div className="uplot-tooltip-item">
<div
className="uplot-tooltip-item-marker"
style={{ borderColor: item.color }}
data-is-legend-marker={true}
/>
<div
className="uplot-tooltip-item-content"
style={{ color: item.color, fontWeight: item.isActive ? 700 : 400 }}
>
{item.label}: {item.tooltipValue}
</div>
</div>
)}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { TooltipContentItem } from '../types';
const FALLBACK_SERIES_COLOR = '#000000';
export function resolveSeriesColor(
stroke: Series.Stroke | undefined,
u: uPlot,
seriesIdx: number,
): string {
if (typeof stroke === 'function') {
return String(stroke(u, seriesIdx));
}
if (typeof stroke === 'string') {
return stroke;
}
return FALLBACK_SERIES_COLOR;
}
export function buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIdx,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}: {
data: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIdx: number | null;
uPlotInstance: uPlot;
yAxisUnit: string;
decimalPrecision?: PrecisionOption;
}): TooltipContentItem[] {
const active: TooltipContentItem[] = [];
const rest: TooltipContentItem[] = [];
for (let idx = 1; idx < series.length; idx += 1) {
const s = series[idx];
if (!s?.show) {
continue;
}
const dataIdx = dataIndexes[idx];
// Skip series with no data at the current cursor position
if (dataIdx === null) {
continue;
}
const raw = data[idx]?.[dataIdx];
const value = Number(raw);
const displayValue = Number.isNaN(value) ? 0 : value;
const isActive = idx === activeSeriesIdx;
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: displayValue,
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(s.stroke, uPlotInstance, idx),
isActive,
};
if (isActive) {
active.push(item);
} else {
rest.push(item);
}
}
return [...active, ...rest];
}

View File

@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import * as Sentry from '@sentry/react';
import { Typography } from 'antd';
import { isEqual } from 'lodash-es';
import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import uPlot, { AlignedData, Options } from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { usePlotContext } from '../context/PlotContext';
import { UPlotChartProps } from './types';
/**
* Check if dimensions have changed
*/
function sameDimensions(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return next.width === prev.width && next.height === prev.height;
}
/**
* Check if data has changed (value equality)
*/
function sameData(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.data, prev.data);
}
/**
* Check if config builder has changed (value equality)
*/
function sameConfig(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.config, prev.config);
}
/**
* Plot component for rendering uPlot charts using the builder pattern
* Manages uPlot instance lifecycle and handles updates efficiently
*/
export default function UPlotChart({
config,
data,
width,
height,
plotRef,
onDestroy,
children,
'data-testid': testId = 'uplot-main-div',
}: UPlotChartProps): JSX.Element {
const { setPlotContextInitialState } = usePlotContext();
const containerRef = useRef<HTMLDivElement>(null);
const plotInstanceRef = useRef<uPlot | null>(null);
const prevPropsRef = useRef<UPlotChartProps | null>(null);
const configUsedForPlotRef = useRef<UPlotConfigBuilder | null>(null);
/**
* Destroy the existing plot instance if present.
*/
const destroyPlot = useCallback((): void => {
if (plotInstanceRef.current) {
onDestroy?.(plotInstanceRef.current);
// Clean up the config builder that was used to create this plot (not the current prop)
if (configUsedForPlotRef.current) {
configUsedForPlotRef.current.destroy();
}
configUsedForPlotRef.current = null;
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
}, [onDestroy, plotRef, setPlotContextInitialState]);
/**
* Initialize or reinitialize the plot
*/
const createPlot = useCallback(() => {
// Destroy existing plot first
destroyPlot();
if (!containerRef.current || width === 0 || height === 0) {
return;
}
// Build configuration from builder
const configOptions = config.getConfig();
// Merge with dimensions
const plotConfig: Options = {
width: Math.floor(width),
height: Math.floor(height),
...configOptions,
} as Options;
// Create new plot instance
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
if (plotRef) {
plotRef(plot);
}
setPlotContextInitialState({
uPlotInstance: plot,
widgetId: config.getWidgetId(),
});
plotInstanceRef.current = plot;
configUsedForPlotRef.current = config;
}, [
config,
data,
width,
height,
plotRef,
destroyPlot,
setPlotContextInitialState,
]);
/**
* Destroy plot when data becomes empty to prevent memory leaks.
* When the "No Data" UI is shown, the container div is unmounted,
* but without this effect the plot instance would remain in memory.
*/
const isDataEmpty = useMemo(() => {
return !!(data && data[0] && data[0].length === 0);
}, [data]);
useEffect(() => {
if (isDataEmpty) {
destroyPlot();
}
}, [isDataEmpty, destroyPlot]);
/**
* Handle initialization and prop changes
*/
useEffect(() => {
const prevProps = prevPropsRef.current;
const currentProps = { config, data, width, height };
// First render - initialize
if (!prevProps) {
createPlot();
prevPropsRef.current = currentProps;
return;
}
// Check if the plot instance's container has been unmounted (e.g., after "No Data" state)
// If so, we need to recreate the plot with the new container
const isPlotOrphaned =
plotInstanceRef.current &&
plotInstanceRef.current.root !== containerRef.current;
// Update dimensions without reinitializing if only size changed
if (
!sameDimensions(prevProps, currentProps) &&
plotInstanceRef.current &&
!isPlotOrphaned
) {
plotInstanceRef.current.setSize({
width: Math.floor(width),
height: Math.floor(height),
});
}
// Reinitialize if config changed or if the plot was orphaned (container changed)
if (!sameConfig(prevProps, currentProps) || isPlotOrphaned) {
createPlot();
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
plotInstanceRef.current.setData(data as AlignedData);
}
prevPropsRef.current = currentProps;
}, [config, data, width, height, createPlot]);
if (isDataEmpty) {
return (
<div
className="uplot-no-data not-found"
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
<LineChart size={48} strokeWidth={0.5} />
<Typography>No Data</Typography>
</div>
);
}
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div style={{ position: 'relative' }}>
<div ref={containerRef} data-testid={testId} />
{children}
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,87 @@
import { ReactNode } from 'react';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
/**
* Props for the Plot component
*/
export interface UPlotChartProps {
/**
* uPlot configuration builder
*/
config: UPlotConfigBuilder;
/**
* Chart data in uPlot.AlignedData format
*/
data: uPlot.AlignedData;
/**
* Chart width in pixels
*/
width: number;
/**
* Chart height in pixels
*/
height: number;
/**
* Optional callback when plot instance is created or destroyed.
* Called with the uPlot instance on create, and with null when the plot is destroyed.
*/
plotRef?: (u: uPlot | null) => void;
/**
* Optional callback when plot is destroyed
*/
onDestroy?: (u: uPlot) => void;
/**
* Children elements (typically plugins)
*/
children?: ReactNode;
/**
* Test ID for the container div
*/
'data-testid'?: string;
}
export interface TooltipRenderArgs {
uPlotInstance: uPlot;
dataIndexes: Array<number | null>;
seriesIndex: number | null;
isPinned: boolean;
dismiss: () => void;
viaSync: boolean;
}
export type TooltipProps = TooltipRenderArgs & {
timezone: string;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
};
export enum LegendPosition {
BOTTOM = 'bottom',
RIGHT = 'right',
}
export interface LegendConfig {
position: LegendPosition;
}
export interface LegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
legendsPerSet?: number;
}
export interface TooltipContentItem {
label: string;
value: number;
tooltipValue: string;
color: string;
isActive: boolean;
}

View File

@@ -0,0 +1,284 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
import getGridColor from '../../uPlotLib/utils/getGridColor';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
PANEL_TYPES.PIE,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
* Based on getAxes utility function patterns
*/
export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
/**
* Build grid configuration based on theme and scale type.
* Supports partial grid config: provided values override defaults.
*/
private buildGridConfig(): uPlot.Axis.Grid | undefined {
const { grid, isDarkMode, isLogScale } = this.props;
const defaultStroke = getGridColor(isDarkMode ?? false);
const defaultWidth = isLogScale ? 0.1 : 0.2;
const defaultShow = true;
// Merge partial or full grid config with defaults
if (grid) {
return {
stroke: grid.stroke ?? defaultStroke,
width: grid.width ?? defaultWidth,
show: grid.show ?? defaultShow,
};
}
return {
stroke: defaultStroke,
width: defaultWidth,
show: defaultShow,
};
}
/**
* Build ticks configuration
*/
private buildTicksConfig(): uPlot.Axis.Ticks | undefined {
const { ticks } = this.props;
// If explicit ticks config provided, use it
if (ticks) {
return ticks;
}
// Build default ticks config
return {
width: 0.3,
show: true,
};
}
/**
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { panelType } = this.props;
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
}
/**
* Build values formatter based on axis type and props
*/
private buildValuesFormatter(): uPlot.Axis.Values | undefined {
const { values, scaleKey } = this.props;
// If explicit values formatter provided, use it
if (values) {
return values;
}
// Route to appropriate formatter based on scale key
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
: undefined;
}
/**
* Calculate axis size from existing size property
*/
private getExistingAxisSize(
self: uPlot,
axis: Axis,
values: string[] | undefined,
axisIdx: number,
cycleNum: number,
): number {
const internalSize = (axis as { _size?: number })._size;
if (internalSize !== undefined) {
return internalSize;
}
const existingSize = axis.size;
if (typeof existingSize === 'function') {
return existingSize(self, values ?? [], axisIdx, cycleNum);
}
return existingSize ?? 0;
}
/**
* Calculate text width for longest value
*/
private calculateTextWidth(
self: uPlot,
axis: Axis,
values: string[] | undefined,
): number {
if (!values || values.length === 0) {
return 0;
}
// Find longest value
const longestVal = values.reduce(
(acc, val) => (val.length > acc.length ? val : acc),
'',
);
if (longestVal === '' || !axis.font?.[0]) {
return 0;
}
// eslint-disable-next-line prefer-destructuring, no-param-reassign
self.ctx.font = axis.font[0];
return self.ctx.measureText(longestVal).width / devicePixelRatio;
}
/**
* Build Y-axis dynamic size calculator
*/
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
return (
self: uPlot,
values: string[] | undefined,
axisIdx: number,
cycleNum: number,
): number => {
const axis = self.axes[axisIdx];
// Bail out, force convergence
if (cycleNum > 1) {
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
}
const gap = this.props.gap ?? 5;
let axisSize = (axis.ticks?.size ?? 0) + gap;
axisSize += this.calculateTextWidth(self, axis, values);
return Math.ceil(axisSize);
};
}
/**
* Build dynamic size calculator for Y-axis
*/
private buildSizeCalculator(): uPlot.Axis.Size | undefined {
const { size, scaleKey } = this.props;
// If explicit size calculator provided, use it
if (size) {
return size;
}
// Y-axis needs dynamic sizing based on text width
if (scaleKey === 'y') {
return this.buildYAxisSizeCalculator();
}
return undefined;
}
/**
* Build stroke color based on props
*/
private buildStrokeColor(): string | undefined {
const { stroke, isDarkMode } = this.props;
if (stroke !== undefined) {
return stroke;
}
if (isDarkMode !== undefined) {
return isDarkMode ? 'white' : 'black';
}
return undefined;
}
getConfig(): Axis {
const {
scaleKey,
label,
show = true,
side = 2, // bottom by default
space,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
const stroke = this.buildStrokeColor();
const axisConfig: Axis = {
scale: scaleKey,
show,
side,
};
// Add properties conditionally
if (label) {
axisConfig.label = label;
}
if (stroke) {
axisConfig.stroke = stroke;
}
if (grid) {
axisConfig.grid = grid;
}
if (ticks) {
axisConfig.ticks = ticks;
}
if (values) {
axisConfig.values = values;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}
if (space !== undefined) {
axisConfig.space = space;
}
if (size) {
axisConfig.size = size;
}
return axisConfig;
}
merge(props: Partial<AxisProps>): void {
this.props = { ...this.props, ...props };
}
}
export type { AxisProps };

View File

@@ -0,0 +1,289 @@
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
import { merge } from 'lodash-es';
import noop from 'lodash-es/noop';
import uPlot, { Cursor, Hooks, Options } from 'uplot';
import {
ConfigBuilder,
ConfigBuilderProps,
DEFAULT_CURSOR_CONFIG,
DEFAULT_PLOT_CONFIG,
LegendItem,
} from './types';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
/**
* Type definitions for uPlot option objects
*/
type LegendConfig = {
show?: boolean;
live?: boolean;
isolate?: boolean;
[key: string]: unknown;
};
/**
* Main builder orchestrator for uPlot configuration
* Manages axes, scales, series, and hooks in a composable way
*/
export class UPlotConfigBuilder extends ConfigBuilder<
ConfigBuilderProps,
Partial<Options>
> {
series: UPlotSeriesBuilder[] = [];
private axes: Record<string, UPlotAxisBuilder> = {};
readonly scales: UPlotScaleBuilder[] = [];
private bands: uPlot.Band[] = [];
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
private plugins: uPlot.Plugin[] = [];
private padding: [number, number, number, number] | undefined;
private legend: LegendConfig | undefined;
private focus: uPlot.Focus | undefined;
private select: uPlot.Select | undefined;
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
private tzDate: ((timestamp: number) => Date) | undefined;
private widgetId: string | undefined;
private onDragSelect: (startTime: number, endTime: number) => void;
private cleanups: Array<() => void> = [];
constructor(args?: ConfigBuilderProps) {
super(args ?? {});
const { widgetId, onDragSelect } = args ?? {};
if (widgetId) {
this.widgetId = widgetId;
}
this.onDragSelect = noop;
if (onDragSelect) {
this.onDragSelect = onDragSelect;
// Add a hook to handle the select event
const cleanup = this.addHook('setSelect', (self: uPlot): void => {
const selection = self.select;
// Only trigger onDragSelect when there's an actual drag range (width > 0)
// A click without dragging produces width === 0, which should be ignored
if (selection && selection.width > 0) {
const startTime = self.posToVal(selection.left, 'x');
const endTime = self.posToVal(selection.left + selection.width, 'x');
this.onDragSelect(startTime * 1000, endTime * 1000);
}
});
this.cleanups.push(cleanup);
}
}
/**
* Add or merge an axis configuration
*/
addAxis(props: AxisProps): void {
const { scaleKey } = props;
if (this.axes[scaleKey]) {
this.axes[scaleKey].merge?.(props);
return;
}
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
/**
* Add or merge a scale configuration
*/
addScale(props: ScaleProps): void {
const current = this.scales.find((v) => v.props.scaleKey === props.scaleKey);
if (current) {
current.merge?.(props);
return;
}
this.scales.push(new UPlotScaleBuilder(props));
}
/**
* Add a series configuration
*/
addSeries(props: SeriesProps): void {
this.series.push(new UPlotSeriesBuilder(props));
}
/**
* Add a hook for extensibility
*/
addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]): () => void {
if (!this.hooks[type]) {
this.hooks[type] = [];
}
(this.hooks[type] as Hooks.Defs[T][]).push(hook);
// Return a function to remove the hook when the component unmounts
return (): void => {
const idx = (this.hooks[type] as Hooks.Defs[T][]).indexOf(hook);
if (idx !== -1) {
(this.hooks[type] as Hooks.Defs[T][]).splice(idx, 1);
}
};
}
/**
* Add a plugin
*/
addPlugin(plugin: uPlot.Plugin): void {
this.plugins.push(plugin);
}
/**
* Add thresholds configuration
*/
addThresholds(options: ThresholdsDrawHookOptions): void {
if (!this.thresholds[options.scaleKey]) {
this.thresholds[options.scaleKey] = options;
const cleanup = this.addHook('draw', thresholdsDrawHook(options));
this.cleanups.push(cleanup);
}
}
/**
* Set bands for stacked charts
*/
setBands(bands: uPlot.Band[]): void {
this.bands = bands;
}
/**
* Set cursor configuration
*/
setCursor(cursor: Cursor): void {
this.cursor = merge({}, this.cursor, cursor);
}
/**
* Set padding
*/
setPadding(padding: [number, number, number, number]): void {
this.padding = padding;
}
/**
* Set legend configuration
*/
setLegend(legend: LegendConfig): void {
this.legend = legend;
}
/**
* Set focus configuration
*/
setFocus(focus: uPlot.Focus): void {
this.focus = focus;
}
/**
* Set select configuration
*/
setSelect(select: uPlot.Select): void {
this.select = select;
}
/**
* Set timezone date function
*/
setTzDate(tzDate: (timestamp: number) => Date): void {
this.tzDate = tzDate;
}
/**
* Get legend items with visibility state restored from localStorage if available
*/
getLegendItems(): Record<number, LegendItem> {
const visibilityMap = this.widgetId
? getStoredSeriesVisibility(this.widgetId)
: null;
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
const seriesConfig = s.getConfig();
const label = seriesConfig.label ?? '';
const seriesIndex = index + 1; // +1 because the first series is the timestamp
// Priority: stored visibility > series config > default (true)
const show = visibilityMap?.get(label) ?? seriesConfig.show ?? true;
acc[seriesIndex] = {
seriesIndex,
color: seriesConfig.stroke,
label,
show,
};
return acc;
}, {} as Record<number, LegendItem>);
}
/**
* Remove all hooks and cleanup functions
*/
destroy(): void {
this.cleanups.forEach((cleanup) => cleanup());
}
/**
* Get the widget id
*/
getWidgetId(): string | undefined {
return this.widgetId;
}
/**
* Build the final uPlot.Options configuration
*/
getConfig(): Partial<Options> {
const config: Partial<Options> = {
...DEFAULT_PLOT_CONFIG,
};
config.series = [
{ value: (): string => '' }, // Base series for timestamp
...this.series.map((s) => s.getConfig()),
];
config.axes = Object.values(this.axes).map((a) => a.getConfig());
config.scales = this.scales.reduce(
(acc, s) => ({ ...acc, ...s.getConfig() }),
{} as Record<string, uPlot.Scale>,
);
config.hooks = this.hooks;
config.select = this.select;
config.cursor = merge({}, DEFAULT_CURSOR_CONFIG, this.cursor);
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.bands.length > 0 ? this.bands : undefined;
if (Array.isArray(this.padding)) {
config.padding = this.padding;
}
if (this.legend) {
config.legend = this.legend;
}
if (this.focus) {
config.focus = this.focus;
}
return config;
}
}

View File

@@ -0,0 +1,155 @@
import { Scale } from 'uplot';
import {
adjustSoftLimitsWithThresholds,
createRangeFunction,
getDistributionConfig,
getFallbackMinMaxTimeStamp,
getRangeConfig,
normalizeLogScaleLimits,
} from '../utils/scale';
import { ConfigBuilder, ScaleProps } from './types';
/**
* Builder for uPlot scale configuration
* Handles creation and merging of scale settings
*/
export class UPlotScaleBuilder extends ConfigBuilder<
ScaleProps,
Record<string, Scale>
> {
private softMin: number | null;
private softMax: number | null;
private min: number | null;
private max: number | null;
constructor(props: ScaleProps) {
super(props);
this.softMin = props.softMin ?? null;
this.softMax = props.softMax ?? null;
this.min = props.min ?? null;
this.max = props.max ?? null;
}
getConfig(): Record<string, Scale> {
const {
scaleKey,
time,
range,
thresholds,
logBase = 10,
padMinBy = 0,
padMaxBy = 0.1,
} = this.props;
// Special handling for time scales (X axis)
if (time) {
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;
// Fallback when min/max are not provided
if (!minTime || !maxTime) {
const { fallbackMin, fallbackMax } = getFallbackMinMaxTimeStamp();
minTime = fallbackMin;
maxTime = fallbackMax;
}
// Align max time to "endTime - 1 minute", rounded down to minute precision
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
maxTime = unixTimestampSeconds;
return {
[scaleKey]: {
time: true,
auto: false,
range: [minTime, maxTime],
},
};
}
const distr = this.props.distribution;
// Adjust softMin/softMax to include threshold values
// This ensures threshold lines are visible within the scale range
const thresholdList = thresholds?.thresholds;
const {
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
} = adjustSoftLimitsWithThresholds(
this.softMin,
this.softMax,
thresholdList,
thresholds?.yAxisUnit,
);
const { min, max, softMin, softMax } = normalizeLogScaleLimits({
distr,
logBase,
limits: {
min: this.min,
max: this.max,
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
},
});
const distribution = getDistributionConfig({
time,
distr,
logBase,
});
const {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
} = getRangeConfig(min, max, softMin, softMax, padMinBy, padMaxBy);
const rangeFn = createRangeFunction({
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
min,
max,
});
let auto = this.props.auto;
auto ??= !time && !hasFixedRange;
return {
[scaleKey]: {
time,
auto,
range: range ?? rangeFn,
...distribution,
},
};
}
merge(props: Partial<ScaleProps>): void {
this.props = { ...this.props, ...props };
if (props.softMin !== undefined) {
this.softMin = props.softMin ?? null;
}
if (props.softMax !== undefined) {
this.softMax = props.softMax ?? null;
}
if (props.min !== undefined) {
this.min = props.min ?? null;
}
if (props.max !== undefined) {
this.max = props.max ?? null;
}
}
}
export type { ScaleProps };

View File

@@ -0,0 +1,232 @@
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import uPlot, { Series } from 'uplot';
import {
ConfigBuilder,
DrawStyle,
FillStyle,
LineInterpolation,
SeriesProps,
VisibilityMode,
} from './types';
/**
* Builder for uPlot series configuration
* Handles creation of series settings
*/
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
private buildLineConfig(
lineColor: string,
lineWidth?: number,
lineStyle?: { fill?: FillStyle; dash?: number[] },
): Partial<Series> {
const lineConfig: Partial<Series> = {
stroke: lineColor,
width: lineWidth ?? 2,
};
if (lineStyle && lineStyle.fill !== FillStyle.Solid) {
if (lineStyle.fill === FillStyle.Dot) {
lineConfig.cap = 'round';
}
lineConfig.dash = lineStyle.dash ?? [10, 10];
}
return lineConfig;
}
/**
* Build path configuration
*/
private buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
}: {
pathBuilder?: Series.PathBuilder | null;
drawStyle: DrawStyle;
lineInterpolation?: LineInterpolation;
}): Partial<Series> {
if (pathBuilder) {
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
return { paths: (): null => null };
}
if (drawStyle !== null) {
return {
paths: (
self: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
): Series.Paths | null => {
const pathsBuilder = getPathBuilder(drawStyle, lineInterpolation);
return pathsBuilder(self, seriesIdx, idx0, idx1);
},
};
}
return {};
}
/**
* Build points configuration
*/
private buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder,
pointsFilter,
drawStyle,
showPoints,
}: {
lineColor: string;
lineWidth?: number;
pointSize?: number;
pointsBuilder: Series.Points.Show | null;
pointsFilter: Series.Points.Filter | null;
drawStyle: DrawStyle;
showPoints?: VisibilityMode;
}): Partial<Series.Points> {
const pointsConfig: Partial<Series.Points> = {
stroke: lineColor,
fill: lineColor,
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
filter: pointsFilter || undefined,
};
if (pointsBuilder) {
pointsConfig.show = pointsBuilder;
} else if (drawStyle === DrawStyle.Points) {
pointsConfig.show = true;
} else if (showPoints === VisibilityMode.Never) {
pointsConfig.show = false;
} else if (showPoints === VisibilityMode.Always) {
pointsConfig.show = true;
}
return pointsConfig;
}
private getLineColor(): string {
const { colorMapping, label, lineColor, isDarkMode } = this.props;
if (!label) {
return lineColor ?? (isDarkMode ? themeColors.white : themeColors.black);
}
return (
lineColor ??
colorMapping[label] ??
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
)
);
}
getConfig(): Series {
const {
drawStyle,
pathBuilder,
pointsBuilder,
pointsFilter,
lineInterpolation,
lineWidth,
lineStyle,
showPoints,
pointSize,
scaleKey,
label,
spanGaps,
show = true,
} = this.props;
const lineColor = this.getLineColor();
const lineConfig = this.buildLineConfig(lineColor, lineWidth, lineStyle);
const pathConfig = this.buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
});
const pointsConfig = this.buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder: pointsBuilder ?? null,
pointsFilter: pointsFilter ?? null,
drawStyle,
showPoints,
});
return {
scale: scaleKey,
label,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
value: (): string => '',
pxAlign: true,
show,
...lineConfig,
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
};
}
}
interface PathBuilders {
linear: Series.PathBuilder;
spline: Series.PathBuilder;
stepBefore: Series.PathBuilder;
stepAfter: Series.PathBuilder;
[key: string]: Series.PathBuilder;
}
let builders: PathBuilders | null = null;
/**
* Get path builder based on draw style and interpolation
*/
function getPathBuilder(
style: DrawStyle,
lineInterpolation?: LineInterpolation,
): Series.PathBuilder {
const pathBuilders = uPlot.paths;
if (!builders) {
const linearBuilder = pathBuilders.linear;
const splineBuilder = pathBuilders.spline;
const steppedBuilder = pathBuilders.stepped;
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
throw new Error('Required uPlot path builders are not available');
}
builders = {
linear: linearBuilder(),
spline: splineBuilder(),
stepBefore: steppedBuilder({ align: -1 }),
stepAfter: steppedBuilder({ align: 1 }),
};
}
if (style === DrawStyle.Line) {
if (lineInterpolation === LineInterpolation.StepBefore) {
return builders.stepBefore;
}
if (lineInterpolation === LineInterpolation.StepAfter) {
return builders.stepAfter;
}
if (lineInterpolation === LineInterpolation.Linear) {
return builders.linear;
}
}
return builders.spline;
}
export type { SeriesProps };

View File

@@ -0,0 +1,199 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Cursor, Options, Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
/**
* Base abstract class for all configuration builders
* Provides a common interface for building uPlot configuration components
*/
export abstract class ConfigBuilder<P, T> {
constructor(public props: P) {}
/**
* Builds and returns the configuration object
*/
abstract getConfig(): T;
/**
* Merges additional properties into the existing configuration
*/
merge?(props: Partial<P>): void;
}
/**
* Props for configuring the uPlot config builder
*/
export interface ConfigBuilderProps {
widgetId?: string;
onDragSelect?: (startTime: number, endTime: number) => void;
}
/**
* Props for configuring an axis
*/
export interface AxisProps {
scaleKey: string;
label?: string;
show?: boolean;
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
stroke?: string;
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
values?: uPlot.Axis.Values;
gap?: number;
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
isDarkMode?: boolean;
isLogScale?: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
decimalPrecision?: PrecisionOption;
}
/**
* Props for configuring a scale
*/
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
}
export interface ScaleProps {
scaleKey: string;
time?: boolean;
min?: number;
max?: number;
softMin?: number;
softMax?: number;
thresholds?: ThresholdsDrawHookOptions;
padMinBy?: number;
padMaxBy?: number;
range?: uPlot.Scale.Range;
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
}
/**
* Props for configuring a series
*/
export enum FillStyle {
Solid = 'solid',
Dash = 'dash',
Dot = 'dot',
Square = 'square',
}
export interface LineStyle {
dash?: Array<number>;
fill?: FillStyle;
}
export enum DrawStyle {
Line = 'line',
Points = 'points',
}
export enum LineInterpolation {
Linear = 'linear',
Spline = 'spline',
StepAfter = 'stepAfter',
StepBefore = 'stepBefore',
}
export enum VisibilityMode {
Always = 'always',
Auto = 'auto',
Never = 'never',
}
export interface SeriesProps {
scaleKey: string;
label?: string;
colorMapping: Record<string, string>;
drawStyle: DrawStyle;
pathBuilder?: Series.PathBuilder;
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
isDarkMode?: boolean;
// Line config
lineColor?: string;
lineInterpolation?: LineInterpolation;
lineStyle?: LineStyle;
lineWidth?: number;
// Points config
pointColor?: string;
pointSize?: number;
showPoints?: VisibilityMode;
}
export interface LegendItem {
seriesIndex: number;
label: uPlot.Series['label'];
color: uPlot.Series['stroke'];
show: boolean;
}
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,
},
cursor: {
focus: {
prox: 30,
},
},
legend: {
show: false,
},
padding: [16, 16, 8, 8],
series: [],
hooks: {},
};
const POINTS_FILL_COLOR = '#FFFFFF';
export const DEFAULT_CURSOR_CONFIG: Cursor = {
drag: { setScale: true },
points: {
one: true,
size: (u, seriesIdx) => (u.series[seriesIdx]?.points?.size ?? 0) * 3,
width: (_u, _seriesIdx, size) => size / 4,
stroke: (u, seriesIdx): string => {
const points = u.series[seriesIdx]?.points;
const strokeFn =
typeof points?.stroke === 'function' ? points.stroke : undefined;
const strokeValue =
strokeFn !== undefined
? strokeFn(u, seriesIdx)
: typeof points?.stroke === 'string'
? points.stroke
: '';
return `${strokeValue}90`;
},
fill: (): string => POINTS_FILL_COLOR,
},
focus: {
prox: 30,
},
};

View File

@@ -0,0 +1,136 @@
import {
createContext,
PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
} from 'react';
import type { SeriesVisibilityItem } from 'container/DashboardContainer/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
export interface PlotContextInitialState {
uPlotInstance: uPlot | null;
widgetId?: string;
}
export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
onFocusSeries: (seriesIndex: number | null) => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
const uPlotInstanceRef = useRef<uPlot | null>(null);
const activeSeriesIndex = useRef<number | undefined>(undefined);
const widgetIdRef = useRef<string | undefined>(undefined);
const setPlotContextInitialState = useCallback(
({ uPlotInstance, widgetId }: PlotContextInitialState): void => {
uPlotInstanceRef.current = uPlotInstance;
widgetIdRef.current = widgetId;
activeSeriesIndex.current = undefined;
},
[],
);
const onToggleSeriesVisibility = useCallback((seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
const isReset = activeSeriesIndex.current === seriesIndex;
activeSeriesIndex.current = isReset ? undefined : seriesIndex;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
const currentSeriesIndex = index;
plot.setSeries(currentSeriesIndex, {
show: isReset || currentSeriesIndex === seriesIndex,
});
});
if (widgetIdRef.current) {
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
(series) => ({
label: series.label ?? '',
show: series.show ?? true,
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}
});
}, []);
const onToggleSeriesOnOff = useCallback((seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
const series = plot.series[seriesIndex];
if (!series) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (widgetIdRef.current) {
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
(series) => ({
label: series.label ?? '',
show: series.show ?? true,
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}
}, []);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
plot.setSeries(
seriesIndex,
{
focus: true,
},
false,
);
}, []);
const value = useMemo(
() => ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onFocusSeries,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onFocusSeries,
],
);
return <PlotContext.Provider value={value}>{children}</PlotContext.Provider>;
};
export const usePlotContext = (): IPlotContext => {
const context = useContext(PlotContext);
if (!context) {
throw new Error('Should be used inside the context');
}
return context;
};

View File

@@ -0,0 +1,12 @@
export interface Threshold {
thresholdValue: number;
thresholdColor?: string;
thresholdUnit?: string;
thresholdLabel?: string;
}
export interface ThresholdsDrawHookOptions {
scaleKey: string;
thresholds: Threshold[];
yAxisUnit?: string;
}

View File

@@ -0,0 +1,142 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
/**
* Syncs legend UI state with the uPlot chart: which series is focused and each series' visibility.
* Subscribes to the config's setSeries hook so legend items stay in sync when series are toggled
* from the chart or from the Legend component.
*
* @param config - UPlot config builder; used to read legend items and to register the setSeries hook
* @param subscribeToFocusChange - When true, updates focusedSeriesIndex when a series gains focus via setSeries
* @returns focusedSeriesIndex, setFocusedSeriesIndex, and legendItemsMap for the Legend component
*/
export default function useLegendsSync({
config,
subscribeToFocusChange = true,
}: {
config: UPlotConfigBuilder;
subscribeToFocusChange?: boolean;
}): {
focusedSeriesIndex: number | null;
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
legendItemsMap: Record<number, LegendItem>;
} {
const [legendItemsMap, setLegendItemsMap] = useState<
Record<number, LegendItem>
>({});
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
null,
);
/** Pending visibility updates (series index -> show) to apply in the next RAF. */
const visibilityUpdatesRef = useRef<Record<number, boolean>>({});
/** RAF id for the batched visibility update; null when no update is scheduled. */
const visibilityRafIdRef = useRef<number | null>(null);
/**
* Applies a batch of visibility updates to legendItemsMap.
* Only updates entries that exist and whose show value changed; returns prev state if nothing changed.
*/
const applyVisibilityUpdates = useCallback(
(updates: Record<number, boolean>): void => {
setLegendItemsMap(
(prev): Record<number, LegendItem> => {
let hasChanges = false;
const next = { ...prev };
for (const [idxStr, show] of Object.entries(updates)) {
const idx = Number(idxStr);
const current = next[idx];
if (!current || current.show === show) {
continue;
}
next[idx] = { ...current, show };
hasChanges = true;
}
return hasChanges ? next : prev;
},
);
},
[],
);
/**
* Queues a single series visibility update and schedules at most one state update per frame.
* Batches multiple visibility changes (e.g. from setSeries) into one setLegendItemsMap call.
*/
const queueVisibilityUpdate = useCallback(
(seriesIndex: number, show: boolean): void => {
visibilityUpdatesRef.current[seriesIndex] = show;
if (visibilityRafIdRef.current !== null) {
return;
}
visibilityRafIdRef.current = requestAnimationFrame(() => {
const updates = visibilityUpdatesRef.current;
visibilityUpdatesRef.current = {};
visibilityRafIdRef.current = null;
applyVisibilityUpdates(updates);
});
},
[applyVisibilityUpdates],
);
/**
* Handler for uPlot's setSeries hook. Updates focused series when opts.focus is set,
* and queues legend visibility updates when opts.show changes so the legend stays in sync.
*/
const handleSetSeries = useCallback(
(_u: uPlot, seriesIndex: number | null, opts: uPlot.Series): void => {
if (subscribeToFocusChange && get(opts, 'focus', false)) {
setFocusedSeriesIndex(seriesIndex);
}
if (!seriesIndex || typeof opts.show !== 'boolean') {
return;
}
queueVisibilityUpdate(seriesIndex, opts.show);
},
[queueVisibilityUpdate, subscribeToFocusChange],
);
// Initialize legend items from config and subscribe to setSeries; cleanup on unmount or config change.
useLayoutEffect(() => {
setLegendItemsMap(config.getLegendItems());
const removeHook = config.addHook('setSeries', handleSetSeries);
return (): void => {
removeHook();
};
}, [config, handleSetSeries]);
// Cancel any pending RAF on unmount to avoid state updates after unmount.
useEffect(
() => (): void => {
if (visibilityRafIdRef.current != null) {
cancelAnimationFrame(visibilityRafIdRef.current);
}
},
[],
);
return {
focusedSeriesIndex,
setFocusedSeriesIndex,
legendItemsMap,
};
}

View File

@@ -0,0 +1,65 @@
import { convertValue } from 'lib/getConvertedValue';
import uPlot, { Hooks } from 'uplot';
import { Threshold, ThresholdsDrawHookOptions } from './types';
export function thresholdsDrawHook(
options: ThresholdsDrawHookOptions,
): Hooks.Defs['draw'] {
const dashSegments = [10, 5];
function addLines(u: uPlot, scaleKey: string, thresholds: Threshold[]): void {
const ctx = u.ctx;
ctx.save();
ctx.lineWidth = 2;
ctx.setLineDash(dashSegments);
const threshold90Percent = ctx.canvas.height * 0.9;
for (let idx = 0; idx < thresholds.length; idx++) {
const threshold = thresholds[idx];
const color = threshold.thresholdColor || 'red';
const yValue = convertValue(
threshold.thresholdValue,
threshold.thresholdUnit,
options.yAxisUnit,
);
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
const x0 = Math.round(u.bbox.left);
const y0 = Math.round(scaleVal);
const x1 = Math.round(u.bbox.left + u.bbox.width);
const y1 = Math.round(scaleVal);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
// Draw threshold label if present
if (threshold.thresholdLabel) {
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
const textX = x1 - textWidth - 20;
const yposHeight = ctx.canvas.height - y1;
const textY = yposHeight > threshold90Percent ? y0 + 15 : y0 - 15;
ctx.fillStyle = color;
ctx.fillText(threshold.thresholdLabel, textX, textY);
}
}
}
const { scaleKey, thresholds } = options;
return (u: uPlot): void => {
const ctx = u.ctx;
addLines(u, scaleKey, thresholds);
ctx.restore();
};
}

View File

@@ -0,0 +1,53 @@
/**
* Checks if a value is invalid for plotting
*
* @param value - The value to check
* @returns true if the value is invalid (should be replaced with null), false otherwise
*/
export function isInvalidPlotValue(value: unknown): boolean {
// Check for null or undefined
if (value === null || value === undefined) {
return true;
}
// Handle number checks
if (typeof value === 'number') {
// Check for NaN, Infinity, -Infinity
return !Number.isFinite(value);
}
// Handle string values
if (typeof value === 'string') {
// Check for string representations of infinity
if (['+Inf', '-Inf', 'Infinity', '-Infinity', 'NaN'].includes(value)) {
return true;
}
// Try to parse the string as a number
const numValue = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
return true;
}
}
// Value is valid for plotting
return false;
}
export function normalizePlotValue(
value: number | string | null | undefined,
): number | null {
if (isInvalidPlotValue(value)) {
return null;
}
// Convert string numbers to actual numbers
if (typeof value === 'string') {
return parseFloat(value);
}
// Already a valid number
return value as number;
}

View File

@@ -0,0 +1,415 @@
/**
* Scale utilities for uPlot Y-axis configuration.
* Handles linear/log distribution, range computation (with padding and soft/hard limits),
* log-scale snapping, and threshold-aware soft limits.
*/
import uPlot, { Range, Scale } from 'uplot';
import { DistributionType, ScaleProps } from '../config/types';
import { Threshold } from '../hooks/types';
import { findMinMaxThresholdValues } from './threshold';
import { LogScaleLimits, RangeFunctionParams } from './types';
/**
* Rounds a number down to the nearest multiple of incr.
* Used for linear scale min so the axis starts on a clean tick.
*/
export function incrRoundDn(num: number, incr: number): number {
return Math.floor(num / incr) * incr;
}
/**
* Rounds a number up to the nearest multiple of incr.
* Used for linear scale max so the axis ends on a clean tick.
*/
export function incrRoundUp(num: number, incr: number): number {
return Math.ceil(num / incr) * incr;
}
/**
* Snaps min/max/softMin/softMax to valid log-scale values (powers of logBase).
* Only applies when distribution is logarithmic; otherwise returns limits unchanged.
* Ensures axis bounds align to log "magnitude" for readable tick labels.
*/
export function normalizeLogScaleLimits({
distr,
logBase,
limits,
}: {
distr?: DistributionType;
logBase: number;
limits: LogScaleLimits;
}): LogScaleLimits {
if (distr !== DistributionType.Logarithmic) {
return limits;
}
const logFn = logBase === 2 ? Math.log2 : Math.log10;
return {
min: normalizeLogLimit(limits.min, logBase, logFn, Math.floor),
max: normalizeLogLimit(limits.max, logBase, logFn, Math.ceil),
softMin: normalizeLogLimit(limits.softMin, logBase, logFn, Math.floor),
softMax: normalizeLogLimit(limits.softMax, logBase, logFn, Math.ceil),
};
}
/**
* Converts a single limit value to the nearest valid log-scale value.
* Rounds the log(value) with roundFn, then returns logBase^exp.
* Values <= 0 or null are returned as-is (log scale requires positive values).
*/
function normalizeLogLimit(
value: number | null,
logBase: number,
logFn: (v: number) => number,
roundFn: (v: number) => number,
): number | null {
if (value == null || value <= 0) {
return value;
}
const exp = roundFn(logFn(value));
return logBase ** exp;
}
/**
* Returns uPlot scale distribution options for the Y axis.
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
*/
export function getDistributionConfig({
time,
distr,
logBase,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
}): Partial<Scale> {
if (time) {
return {};
}
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,
};
}
/**
* Builds uPlot range config and flags for the range function.
* - rangeConfig: pad, hard, soft, mode for min and max (used by uPlot.rangeNum / rangeLog).
* - hardMinOnly / hardMaxOnly: true when only a hard limit is set (no soft), so range uses that bound.
* - hasFixedRange: true when both min and max are hard-only (fully fixed axis).
*/
export function getRangeConfig(
min: number | null,
max: number | null,
softMin: number | null,
softMax: number | null,
padMinBy: number,
padMaxBy: number,
): {
rangeConfig: Range.Config;
hardMinOnly: boolean;
hardMaxOnly: boolean;
hasFixedRange: boolean;
} {
// uPlot: mode 3 = auto pad from data; mode 1 = respect soft limit
const softMinMode: Range.SoftMode = softMin == null ? 3 : 1;
const softMaxMode: Range.SoftMode = softMax == null ? 3 : 1;
const rangeConfig: Range.Config = {
min: {
pad: padMinBy,
hard: min ?? -Infinity,
soft: softMin !== null ? softMin : undefined,
mode: softMinMode,
},
max: {
pad: padMaxBy,
hard: max ?? Infinity,
soft: softMax !== null ? softMax : undefined,
mode: softMaxMode,
},
};
const hardMinOnly = softMin == null && min != null;
const hardMaxOnly = softMax == null && max != null;
const hasFixedRange = hardMinOnly && hardMaxOnly;
return {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
};
}
/**
* Initial [min, max] for the range pipeline. Returns null when we have no data and no fixed range
* (so the caller can bail and return [dataMin, dataMax] unchanged).
*/
function getInitialMinMax(
dataMin: number | null,
dataMax: number | null,
hasFixedRange: boolean,
): Range.MinMax | null {
if (!hasFixedRange && dataMin == null && dataMax == null) {
return null;
}
return [dataMin, dataMax];
}
/**
* Computes the linear-scale range using uPlot.rangeNum.
* Uses hard min/max when hardMinOnly/hardMaxOnly; otherwise uses data min/max. Applies padding via rangeConfig.
*/
function getLinearScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
): Range.MinMax {
const { rangeConfig, hardMinOnly, hardMaxOnly, min, max } = params;
const resolvedMin = hardMinOnly ? min : dataMin;
const resolvedMax = hardMaxOnly ? max : dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeNum(resolvedMin, resolvedMax, rangeConfig);
}
/**
* Computes the log-scale range using uPlot.rangeLog.
* Resolves min/max from params or data, then delegates to uPlot's log range helper.
*/
function getLogScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
logBase?: uPlot.Scale['log'],
): Range.MinMax {
const { min, max } = params;
const resolvedMin = min ?? dataMin;
const resolvedMax = max ?? dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeLog(
resolvedMin,
resolvedMax,
(logBase ?? 10) as 2 | 10,
true,
);
}
/**
* Snaps linear scale min down and max up to whole numbers so axis bounds are clean.
*/
function roundLinearRange(minMax: Range.MinMax): Range.MinMax {
const [currentMin, currentMax] = minMax;
let roundedMin = currentMin;
let roundedMax = currentMax;
if (roundedMin != null) {
roundedMin = incrRoundDn(roundedMin, 1);
}
if (roundedMax != null) {
roundedMax = incrRoundUp(roundedMax, 1);
}
return [roundedMin, roundedMax];
}
/**
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
*/
function adjustLogRange(
minMax: Range.MinMax,
logBase: number,
logFn: (v: number) => number,
): Range.MinMax {
let [currentMin, currentMax] = minMax;
if (currentMin != null) {
const minExp = Math.floor(logFn(currentMin));
currentMin = logBase ** minExp;
}
if (currentMax != null) {
const maxExp = Math.ceil(logFn(currentMax));
currentMax = logBase ** maxExp;
if (currentMin === currentMax) {
currentMax *= logBase;
}
}
return [currentMin, currentMax];
}
/**
* For linear scales (distr === 1), clamps the computed range to the configured hard min/max when
* hardMinOnly/hardMaxOnly are set. No-op for log scales.
*/
function applyHardLimits(
minMax: Range.MinMax,
params: RangeFunctionParams,
distr: number,
): Range.MinMax {
let [currentMin, currentMax] = minMax;
if (distr !== 1) {
return [currentMin, currentMax];
}
const { hardMinOnly, hardMaxOnly, min, max } = params;
if (hardMinOnly && min != null) {
currentMin = min;
}
if (hardMaxOnly && max != null) {
currentMax = max;
}
return [currentMin, currentMax];
}
/**
* If the range is invalid (min >= max), returns a safe default: [1, 100] for log (distr 3), [0, 100] for linear.
*/
function enforceValidRange(minMax: Range.MinMax, distr: number): Range.MinMax {
const [currentMin, currentMax] = minMax;
if (currentMin != null && currentMax != null && currentMin >= currentMax) {
return [distr === 3 ? 1 : 0, 100];
}
return minMax;
}
/**
* Creates the uPlot range function for a scale. Called by uPlot with (u, dataMin, dataMax, scaleKey).
* Pipeline: initial min/max -> linear or log range (with padding) -> rounding/snapping -> hard limits -> valid range.
*/
export function createRangeFunction(
params: RangeFunctionParams,
): Range.Function {
return (
u: uPlot,
dataMin: number | null,
dataMax: number | null,
scaleKey: string,
): Range.MinMax => {
const scale = u.scales[scaleKey];
const initialMinMax = getInitialMinMax(
dataMin,
dataMax,
params.hasFixedRange,
);
if (!initialMinMax) {
return [dataMin, dataMax];
}
let minMax: Range.MinMax = initialMinMax;
const logBase = scale.log;
if (scale.distr === 1) {
minMax = getLinearScaleRange(minMax, params, dataMin, dataMax);
minMax = roundLinearRange(minMax);
} else if (scale.distr === 3) {
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
}
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);
return enforceValidRange(minMax, scale.distr ?? 1);
};
}
/**
* Expands softMin/softMax so that all threshold lines fall within the soft range and stay visible.
* Converts threshold values to yAxisUnit, then takes the min/max; softMin is lowered (or set) to
* include the smallest threshold, softMax is raised (or set) to include the largest.
*/
export function adjustSoftLimitsWithThresholds(
softMin: number | null,
softMax: number | null,
thresholds?: Threshold[],
yAxisUnit?: string,
): {
softMin: number | null;
softMax: number | null;
} {
if (!thresholds || thresholds.length === 0) {
return { softMin, softMax };
}
const [minThresholdValue, maxThresholdValue] = findMinMaxThresholdValues(
thresholds,
yAxisUnit,
);
if (minThresholdValue === null && maxThresholdValue === null) {
return { softMin, softMax };
}
const adjustedSoftMin =
minThresholdValue !== null
? softMin !== null
? Math.min(softMin, minThresholdValue)
: minThresholdValue
: softMin;
const adjustedSoftMax =
maxThresholdValue !== null
? softMax !== null
? Math.max(softMax, maxThresholdValue)
: maxThresholdValue
: softMax;
return {
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
};
}
/**
* Returns fallback time bounds (min/max) as Unix timestamps in seconds when no
* data range is available. Uses the last 24 hours: from one day ago to now.
*/
export function getFallbackMinMaxTimeStamp(): {
fallbackMin: number;
fallbackMax: number;
} {
const currentDate = new Date();
// Get the Unix timestamp (milliseconds since January 1, 1970)
const currentTime = currentDate.getTime();
const currentUnixTimestamp = Math.floor(currentTime / 1000);
// Calculate the date and time one day ago
const oneDayAgoUnixTimestamp = Math.floor(
(currentDate.getTime() - 86400000) / 1000,
); // 86400000 milliseconds in a day
return {
fallbackMin: oneDayAgoUnixTimestamp,
fallbackMax: currentUnixTimestamp,
};
}

View File

@@ -0,0 +1,39 @@
import { convertValue } from 'lib/getConvertedValue';
import { Threshold } from '../hooks/types';
/**
* Find min and max threshold values after converting to the target unit
*/
export function findMinMaxThresholdValues(
thresholds: Threshold[],
yAxisUnit?: string,
): [number | null, number | null] {
if (!thresholds || thresholds.length === 0) {
return [null, null];
}
let minThresholdValue: number | null = null;
let maxThresholdValue: number | null = null;
thresholds.forEach((threshold) => {
const { thresholdValue, thresholdUnit } = threshold;
if (thresholdValue === undefined) {
return;
}
const compareValue = convertValue(thresholdValue, thresholdUnit, yAxisUnit);
if (compareValue === null) {
return;
}
if (minThresholdValue === null || compareValue < minThresholdValue) {
minThresholdValue = compareValue;
}
if (maxThresholdValue === null || compareValue > maxThresholdValue) {
maxThresholdValue = compareValue;
}
});
return [minThresholdValue, maxThresholdValue];
}

View File

@@ -0,0 +1,17 @@
import { Range } from 'uplot';
export type LogScaleLimits = {
min: number | null;
max: number | null;
softMin: number | null;
softMax: number | null;
};
export type RangeFunctionParams = {
rangeConfig: Range.Config;
hardMinOnly: boolean;
hardMaxOnly: boolean;
hasFixedRange: boolean;
min: number | null;
max: number | null;
};

View File

@@ -45,6 +45,8 @@ import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { useDashboardVariables } from '../../hooks/dashboard/useDashboardVariables';
import { updateDashboardVariablesStore } from './store/dashboardVariablesStore';
import {
DashboardSortOrder,
IDashboardContext,
@@ -196,6 +198,16 @@ export function DashboardProvider({
: isDashboardWidgetPage?.params.dashboardId) || '';
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
const dashboardVariables = useDashboardVariables();
useEffect(() => {
const existingVariables = dashboardVariables;
const updatedVariables = selectedDashboard?.data.variables || {};
if (!isEqual(existingVariables, updatedVariables)) {
updateDashboardVariablesStore(updatedVariables);
}
}, [selectedDashboard]);
const {
currentDashboard,

View File

@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { useDashboardVariables } from '../../../hooks/dashboard/useDashboardVariables';
import { initializeDefaultVariables } from '../initializeDefaultVariables';
import { normalizeUrlValueForVariable } from '../normalizeUrlValue';
@@ -55,6 +56,7 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
function TestComponent(): JSX.Element {
const { dashboardResponse, dashboardId, selectedDashboard } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
return (
<div>
@@ -65,9 +67,7 @@ function TestComponent(): JSX.Element {
{dashboardResponse.isFetching.toString()}
</div>
<div data-testid="dashboard-variables">
{selectedDashboard?.data?.variables
? JSON.stringify(selectedDashboard.data.variables)
: 'null'}
{dashboardVariables ? JSON.stringify(dashboardVariables) : 'null'}
</div>
<div data-testid="dashboard-data">
{selectedDashboard?.data?.title || 'No Title'}

View File

@@ -0,0 +1,17 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import createStore from './store';
// export type IDashboardVariables = DashboardData['variables'];
export type IDashboardVariables = Record<string, IDashboardVariable>;
export const dashboardVariablesStore = createStore<IDashboardVariables>({});
export function updateDashboardVariablesStore(
variables: Partial<IDashboardVariables>,
): void {
dashboardVariablesStore.update((currentVariables) => ({
...currentVariables,
...variables,
}));
}

View File

@@ -0,0 +1,44 @@
import { produce } from 'immer';
type ListenerFn = () => void;
export default function createStore<T>(
init: T,
): {
set: (setter: any) => void;
update: (updater: (draft: T) => void) => void;
subscribe: (listener: ListenerFn) => () => void;
getSnapshot: () => T;
} {
let listeners: ListenerFn[] = [];
let state = init;
function emitChange(): void {
for (const listener of listeners) {
listener();
}
}
function set(setter: any): void {
state = produce(state, setter);
emitChange();
}
function update(updater: (draft: T) => void): void {
state = produce(state, updater);
emitChange();
}
return {
set,
update,
subscribe(listener: ListenerFn): () => void {
listeners = [...listeners, listener];
return (): void => {
listeners = listeners.filter((l) => l !== listener);
};
},
getSnapshot(): T {
return state;
},
};
}

View File

@@ -12030,6 +12030,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
immer@11.1.3:
version "11.1.3"
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
immer@^9.0.6:
version "9.0.21"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"