feat: allow custom color pallete in panel for legends (#8063)

This commit is contained in:
SagarRajput-7 2025-05-27 14:49:35 +05:30 committed by GitHub
parent aaeffae1bd
commit 8990fb7a73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 476 additions and 13 deletions

View File

@ -6,7 +6,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@ -27,6 +27,7 @@ function LeftContainer({
requestData,
setRequestData,
isLoadingPanelData,
setQueryResponse,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
const { selectedDashboard } = useDashboard();
@ -49,6 +50,13 @@ function LeftContainer({
},
);
// Update parent component with query response for legend colors
useEffect(() => {
if (setQueryResponse) {
setQueryResponse(queryResponse);
}
}, [queryResponse, setQueryResponse]);
return (
<>
<WidgetGraph

View File

@ -0,0 +1,169 @@
.legend-colors-container {
.legend-colors-collapse {
.ant-collapse-header {
padding: 8px 0 !important;
}
.ant-collapse-content-box {
padding: 0 0 12px 0 !important;
}
}
.legend-colors-content {
.legend-colors-header {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.legend-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding-top: 4px;
/* Webkit scrollbar styling */
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 0.5px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-300);
}
/* Firefox scrollbar styling */
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-400) transparent;
}
.legend-item-wrapper {
.ant-color-picker-trigger {
width: 100%;
height: auto;
padding: 0;
border: none;
background: transparent;
box-shadow: none;
&:hover {
border: none;
box-shadow: none;
}
&:focus {
border: none;
box-shadow: none;
outline: none;
}
}
}
.legend-item {
display: grid;
grid-template-columns: 1fr max-content;
gap: 12px;
align-items: center;
justify-content: center;
padding: 6px 8px;
border-radius: 4px;
transition: all 0.2s ease;
cursor: pointer;
width: 100%;
border: 1px solid transparent;
&:hover {
background-color: var(--bg-slate-400);
border-color: var(--bg-slate-500);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
.legend-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
.legend-marker {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label-text {
flex: 1;
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
}
.legend-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.reset-link {
font-size: 11px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
.legend-colors-header {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
}
.lightMode {
.legend-colors-container {
.legend-colors-content {
.legend-items {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-500);
}
scrollbar-color: var(--bg-vanilla-400) transparent;
}
.legend-item {
&:hover {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-slate-100);
}
}
}
}
}

View File

@ -0,0 +1,205 @@
import './LegendColors.styles.scss';
import { Button, Collapse, ColorPicker, Tooltip, Typography } from 'antd';
import { themeColors } from 'constants/theme';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Palette } from 'lucide-react';
import {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
// Component for legend text with conditional tooltip
function LegendText({ label }: { label: string }): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const checkOverflow = (): void => {
if (textRef.current) {
const isTextOverflowing =
textRef.current.scrollWidth > textRef.current.clientWidth;
setIsOverflowing(isTextOverflowing);
}
};
checkOverflow();
// Check on window resize
window.addEventListener('resize', checkOverflow);
return (): void => window.removeEventListener('resize', checkOverflow);
}, [label]);
return (
<Tooltip title={label} open={isOverflowing ? undefined : false}>
<span ref={textRef} className="legend-label-text">
{label}
</span>
</Tooltip>
);
}
interface LegendColorsProps {
customLegendColors: Record<string, string>;
setCustomLegendColors: Dispatch<SetStateAction<Record<string, string>>>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
function LegendColors({
customLegendColors,
setCustomLegendColors,
queryResponse = null as any,
}: LegendColorsProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
// Get legend labels from query response or current query
const legendLabels = useMemo(() => {
if (queryResponse?.data?.payload?.data?.result) {
return queryResponse.data.payload.data.result.map((item: any) =>
getLabelName(item.metric || {}, item.queryName || '', item.legend || ''),
);
}
// Fallback to query data if no response available
return currentQuery.builder.queryData.map((query) =>
getLabelName({}, query.queryName || '', query.legend || ''),
);
}, [queryResponse, currentQuery]);
// Get current or default color for a legend
const getColorForLegend = (label: string): string => {
if (customLegendColors[label]) {
return customLegendColors[label];
}
return generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
};
// Handle color change
const handleColorChange = (label: string, color: string): void => {
setCustomLegendColors((prev) => ({
...prev,
[label]: color,
}));
};
// Reset to default color
const resetToDefault = (label: string): void => {
setCustomLegendColors((prev) => {
const updated = { ...prev };
delete updated[label];
return updated;
});
};
// Reset all colors to default
const resetAllColors = (): void => {
setCustomLegendColors({});
};
const items = [
{
key: 'legend-colors',
label: (
<section className="legend-colors-header">
<Palette size={16} />
<Typography.Text className="typography">Legend Colors</Typography.Text>
</section>
),
children: (
<div className="legend-colors-content">
{legendLabels.length === 0 ? (
<Typography.Text type="secondary">
No legends available. Run a query to see legend options.
</Typography.Text>
) : (
<>
<div className="legend-colors-header">
<Button
size="small"
type="text"
onClick={resetAllColors}
disabled={Object.keys(customLegendColors).length === 0}
>
Reset All
</Button>
</div>
<div className="legend-items">
{legendLabels.map((label: string) => (
<div key={label} className="legend-item-wrapper">
<ColorPicker
value={getColorForLegend(label)}
onChange={(color): void =>
handleColorChange(label, color.toHexString())
}
size="small"
showText={false}
trigger="click"
>
<div className="legend-item">
<div className="legend-info">
<div
className="legend-marker"
style={{ backgroundColor: getColorForLegend(label) }}
/>
<LegendText label={label} />
</div>
{customLegendColors[label] && (
<div className="legend-actions">
<Typography.Link
className="reset-link"
onClick={(e): void => {
e.stopPropagation();
resetToDefault(label);
}}
>
Reset
</Typography.Link>
</div>
)}
</div>
</ColorPicker>
</div>
))}
</div>
</>
)}
</div>
),
},
];
return (
<div className="legend-colors-container">
<Collapse
items={items}
ghost
size="small"
expandIconPosition="end"
className="legend-colors-collapse"
accordion
/>
</div>
);
}
LegendColors.defaultProps = {
queryResponse: null,
};
export default LegendColors;

View File

@ -174,6 +174,10 @@
gap: 8px;
}
.legend-colors {
margin-top: 16px;
}
.panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400);

View File

@ -164,3 +164,17 @@ export const panelTypeVsLegendPosition: {
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLegendColors: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: true,
[PANEL_TYPES.BAR]: true,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.HISTOGRAM]: true,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@ -30,11 +30,14 @@ import {
useRef,
useState,
} from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
LegendPosition,
Widgets,
} from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
@ -44,6 +47,7 @@ import {
panelTypeVsColumnUnitPreferences,
panelTypeVsCreateAlert,
panelTypeVsFillSpan,
panelTypeVsLegendColors,
panelTypeVsLegendPosition,
panelTypeVsLogScale,
panelTypeVsPanelTimePreferences,
@ -52,6 +56,7 @@ import {
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
} from './constants';
import LegendColors from './LegendColors/LegendColors';
import ThresholdSelector from './Threshold/ThresholdSelector';
import { ThresholdProps } from './Threshold/types';
import { timePreferance } from './timeItems';
@ -105,6 +110,9 @@ function RightContainer({
setIsLogScale,
legendPosition,
setLegendPosition,
customLegendColors,
setCustomLegendColors,
queryResponse,
}: RightContainerProps): JSX.Element {
const { selectedDashboard } = useDashboard();
const [inputValue, setInputValue] = useState(title);
@ -136,6 +144,7 @@ function RightContainer({
const allowPanelTimePreference =
panelTypeVsPanelTimePreferences[selectedGraph];
const allowLegendPosition = panelTypeVsLegendPosition[selectedGraph];
const allowLegendColors = panelTypeVsLegendColors[selectedGraph];
const allowPanelColumnPreference =
panelTypeVsColumnUnitPreferences[selectedGraph];
@ -462,6 +471,16 @@ function RightContainer({
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</section>
{allowCreateAlerts && (
@ -529,10 +548,17 @@ interface RightContainerProps {
setIsLogScale: Dispatch<SetStateAction<boolean>>;
legendPosition: LegendPosition;
setLegendPosition: Dispatch<SetStateAction<LegendPosition>>;
customLegendColors: Record<string, string>;
setCustomLegendColors: Dispatch<SetStateAction<Record<string, string>>>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
RightContainer.defaultProps = {
selectedWidget: undefined,
queryResponse: null,
};
export default RightContainer;

View File

@ -34,9 +34,11 @@ import {
} from 'providers/Dashboard/util';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { generatePath, useParams } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
Dashboard,
@ -44,6 +46,7 @@ import {
Widgets,
} from 'types/api/dashboard/getAll';
import { IField } from 'types/api/logs/fields';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@ -191,6 +194,10 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
const [legendPosition, setLegendPosition] = useState<LegendPosition>(
selectedWidget?.legendPosition || LegendPosition.BOTTOM,
);
const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string>
>(selectedWidget?.customLegendColors || {});
const [saveModal, setSaveModal] = useState(false);
const [discardModal, setDiscardModal] = useState(false);
@ -257,6 +264,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
selectedTracesFields,
isLogScale,
legendPosition,
customLegendColors,
columnWidths: columnWidths?.[selectedWidget?.id],
};
});
@ -282,6 +290,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
stackedBarChart,
isLogScale,
legendPosition,
customLegendColors,
columnWidths,
]);
@ -340,6 +349,11 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
// hence while changing the query contains the older value and the processing logic fails
const [isLoadingPanelData, setIsLoadingPanelData] = useState<boolean>(false);
// State to hold query response for sharing between left and right containers
const [queryResponse, setQueryResponse] = useState<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>(null as any);
// request data should be handled by the parent and the child components should consume the same
// this has been moved here from the left container
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
@ -482,6 +496,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
selectedLogFields: selectedWidget?.selectedLogFields || [],
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
customLegendColors: selectedWidget?.customLegendColors || {},
},
]
: [
@ -510,6 +525,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
selectedLogFields: selectedWidget?.selectedLogFields || [],
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
customLegendColors: selectedWidget?.customLegendColors || {},
},
...afterWidgets,
],
@ -723,6 +739,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
requestData={requestData}
setRequestData={setRequestData}
isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
/>
)}
</OverlayScrollbar>
@ -766,6 +783,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
setIsLogScale={setIsLogScale}
legendPosition={legendPosition}
setLegendPosition={setLegendPosition}
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}

View File

@ -27,6 +27,11 @@ export interface WidgetGraphProps {
requestData: GetQueryResultsProps;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
isLoadingPanelData: boolean;
setQueryResponse?: Dispatch<
SetStateAction<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>
>;
}
export type WidgetGraphContainerProps = {

View File

@ -50,14 +50,19 @@ function PiePanelWrapper({
color: string;
}[] = [].concat(
...(panelData
.map((d) => ({
label: getLabelName(d.metric, d.queryName || '', d.legend || ''),
value: d.values?.[0]?.[1],
color: generateColor(
getLabelName(d.metric, d.queryName || '', d.legend || ''),
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
}))
.map((d) => {
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
return {
label,
value: d.values?.[0]?.[1],
color:
widget?.customLegendColors?.[label] ||
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
};
})
.filter((d) => d !== undefined) as never[]),
);

View File

@ -138,6 +138,7 @@ function UplotPanelWrapper({
timezone: timezone.value,
customSeries,
isLogScale: widget?.isLogScale,
colorMapping: widget?.customLegendColors,
enhancedLegend: true, // Enable enhanced legend
legendPosition: widget?.legendPosition,
}),
@ -166,6 +167,7 @@ function UplotPanelWrapper({
customSeries,
widget?.isLogScale,
widget?.legendPosition,
widget?.customLegendColors,
],
);

View File

@ -34,6 +34,7 @@ const getSeries = ({
panelType,
hiddenGraph,
isDarkMode,
colorMapping,
}: GetSeriesProps): uPlot.Options['series'] => {
const configurations: uPlot.Series[] = [
{ label: 'Timestamp', stroke: 'purple' },
@ -52,10 +53,12 @@ const getSeries = ({
legend || '',
);
const color = generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
const color =
colorMapping?.[label] ||
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
const pointSize = seriesList[i].values.length > 1 ? 5 : 10;
const showPoints = !(seriesList[i].values.length > 1);
@ -105,6 +108,7 @@ export type GetSeriesProps = {
hiddenGraph?: {
[key: string]: boolean;
};
colorMapping?: Record<string, string>;
};
export default getSeries;

View File

@ -117,6 +117,7 @@ export interface IBaseWidget {
isLogScale?: boolean;
columnWidths?: Record<string, number>;
legendPosition?: LegendPosition;
customLegendColors?: Record<string, string>;
}
export interface Widgets extends IBaseWidget {
query: Query;