Compare commits

..

6 Commits

Author SHA1 Message Date
Abhi Kumar
08b2296138 feat(dashboard): search the bottom legend
The chart layout decides when the row appears, not the legend: it is the
one reserving the height, and a search row that is not reserved for eats
a row of series. One toolbar serves both placements, with position
driving the layout only.
2026-09-23 15:22:24 +05:30
Abhi Kumar
b98ccf49e5 fix(dashboard): take red out of the series colour palettes
Red is reserved for thresholds and error states, so the replacements are
rotated onto free hues rather than dropped: generateColor indexes by
`hash % Object.keys(...).length`, and removing entries would recolour
every existing chart. Keys are renamed in place to stay honest.
2026-09-23 11:53:24 +05:30
Abhi Kumar
f406a1853b fix(dashboard): drop the date from the tooltip for points on today 2026-09-23 11:53:16 +05:30
Abhi Kumar
f4d3420ab0 fix(dashboard): reduce the gap between bars
The gap was wider than half a bar; 0.85 leaves just enough to separate
them. Applies to bar and histogram panels, which share the default.
2026-09-23 11:53:06 +05:30
Gaurav Tewari
f1c9e0f1d0 feat(llm-observability): add ai o11y analytics events (#12952)
#### Description

- Renames AI Observability explorer events from `Traces Explorer: *` to
`AI Observability Explorer: *`, so they no longer mix with the regular
Traces Explorer events.
- Adds page-visit events for Overview, Attribute Mapping and Model
Pricing.
- Adds action events: attribute mapping saved and test run; model cost
saved and deleted; unpriced model mapped.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 05:05:06 +00:00
Gaurav Tewari
10c0af327b fix: failing e2e for llm (#12953)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Attribute-mapping e2e now adds a condition key when creating its
group.
- Since #12809 the backend rejects groups without conditions (`400
condition must list at least one attribute or resource substring`), so
the spec failed on save.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Follow-up: we should add check on frontend as well for #12809 ( we
have already decided to add this later )

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 04:43:36 +00:00
32 changed files with 370 additions and 118 deletions

View File

@@ -23,4 +23,3 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [SQL](sql.md) - Database and SQL patterns
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
sdsdcdsc

View File

@@ -129,6 +129,10 @@ const themeColors = {
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
/* Series palette (dark). Hues in the red band are deliberately absent: red is
reserved for thresholds and error states, so an arbitrary series must never
claim it. generateColor indexes by `hash % Object.keys(...).length`, so
adding or removing an entry recolors every existing chart. */
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',
@@ -152,13 +156,13 @@ const themeColors = {
// Oranges (3)
festivalOrange: '#F2994A',
coralOrange: '#E17055',
amber1: '#E1A155',
pumpkin: '#FF7F50',
// Reds (3)
radicalRed: '#FF1A66',
crimsonRed: '#EB5757',
fireRed: '#E10600',
// Olives / Greens (3)
olive1: '#DFC33A',
olive2: '#D5E55D',
green7: '#81C220',
// Pinks (3)
hotPink: '#E84393',
@@ -191,9 +195,9 @@ const themeColors = {
orange1: '#D35400',
orange2: '#E67E22',
orange3: '#F5B041',
red1: '#C0392B',
red2: '#E74C3C',
red3: '#EC7063',
green8: '#5AC02B',
green9: '#48E043',
green10: '#68E788',
pink1: '#D81B60',
pink2: '#E91E63',
pink3: '#F06292',
@@ -212,9 +216,9 @@ const themeColors = {
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
crimson1: '#C0392B',
crimson2: '#E74C3C',
crimson3: '#EC7063',
teal7: '#2BC07B',
teal8: '#43E0C5',
teal9: '#68D9E7',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
@@ -224,18 +228,18 @@ const themeColors = {
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
blush1: '#FF6F91',
cyan4: '#83C2EB',
blush2: '#FF85A2',
blush3: '#FFA0B3',
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
tomato1: '#E74C3C',
tomato2: '#EC7063',
tomato3: '#F1948A',
salmon1: '#FF6B6B',
salmon2: '#FF8787',
salmon3: '#FFA1A1',
blue7: '#4375E0',
blue8: '#686DE7',
indigo1: '#A68EED',
indigo2: '#B980EA',
purple6: '#EE98D9',
olive3: '#F2F0AE',
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
@@ -254,9 +258,9 @@ const themeColors = {
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
red4: '#C0392B',
red5: '#E74C3C',
red6: '#EC7063',
purple4: '#A52BC0',
purple5: '#E043D0',
magenta4: '#E768B5',
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
@@ -267,18 +271,19 @@ const themeColors = {
gold5: '#F1C40F',
gold6: '#F4D03F',
},
/* Series palette (light). Same red-free constraint as chartcolors above. */
lightModeColor: {
radicalRed: '#D81B60',
magenta1: '#D81B60',
dodgerBlueDark: '#1E5BD9',
steelgrey: '#344B6B',
steelpurple: '#5E548E',
steelindigo: '#8E4A7C',
steelpink: '#B63A6F',
steelcoral: '#E14B5A',
amber1: '#E1A14B',
steelorange: '#E76F2F',
steelgold: '#E09B00',
steelrust: '#C93A50',
olive1: '#C9BD3A',
steelgreen: '#2F7D69',
mediumOrchidDark: '#8E24AA',
@@ -286,17 +291,17 @@ const themeColors = {
seaGreen: '#1E7F5A',
turquoiseBlueDark: '#007EA7',
silverDark: '#5F5F5F',
outrageousOrangeDark: '#E64A19',
roseBudDark: '#D84315',
green1: '#ACDB24',
green2: '#66CC21',
deepSkyBlueDark: '#0277BD',
royalBlue: '#2A4FDB',
avocadoDark: '#6B6B1E',
mintGreenDark: '#2E9E55',
chestnut: '#8B3A3A',
green3: '#3F8B3A',
limaDark: '#5C7F00',
olive: '#6E7F00',
beautyBushDark: '#C93C3C',
green4: '#3CC964',
danube: '#4F6FB3',
oliveDrab: '#4F7F1A',
@@ -304,13 +309,13 @@ const themeColors = {
electricLimeDark: '#6B8F00',
robin: '#2F4FCC',
harleyOrange: '#CC2E12',
teal1: '#1FBF83',
gladeGreen: '#4F7F46',
hemlock: '#5C5C45',
vidaLoca: '#3D6B00',
rust: '#993300',
red: '#C62828',
teal2: '#28C6C1',
blue: '#1A237E',
green: '#1B7F3A',
purple: '#6A1B9A',
@@ -320,7 +325,7 @@ const themeColors = {
brown: '#7A3A1E',
teal: '#006D6F',
limeDark: '#4C8C2B',
maroon: '#6D1B1B',
cyan1: '#1B546D',
navy: '#0D1B5E',
gray: '#616161',
@@ -328,25 +333,25 @@ const themeColors = {
indigo: '#303F9F',
slateGray: '#556B7C',
chocolate: '#9C4A1A',
tomato: '#E53935',
blue1: '#3B74DF',
steelBlue: '#3A6EA5',
peruDark: '#B35E00',
darkOliveGreen: '#445B1F',
indianRed: '#B04040',
blue2: '#4041B0',
mediumSlateBlue: '#5C6BC0',
rosyBrownDark: '#A94444',
indigo1: '#6644A9',
darkSlateGray: '#2E4A4A',
fuchsia: '#C511C5',
salmonDark: '#E64A3C',
darkSalmonDark: '#C85A3A',
indigo2: '#AD42E0',
purple1: '#C83AC5',
paleVioletRedDark: '#C2186A',
mediumPurple: '#7E57C2',
darkOrchid: '#7B1FA2',
mediumSeaGreenDark: '#2E8B57',
lightCoralDark: '#E57373',
purple2: '#E573BC',
gold: '#D4AF37',
sandyBrownDark: '#C76A15',

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingActions from './components/AttributeMappingActions/AttributeMappingActions';
@@ -20,6 +21,10 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
const groupDrawer = useGroupFormDrawer();
const spanTest = useTestSpanMapper(editor.snapshot, editor.groups);
useEffect(() => {
void logEvent('AI Observability Attribute Mapping: Page visited', {});
}, []);
const { discard } = editor;
// Discarding wipes the whole working copy, so gate it behind a confirm
// prompt rather than firing straight from the button.

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import logEvent from 'api/common/logEvent';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
@@ -126,6 +127,7 @@ export function useTestSpanMapper(
{ data: body },
{
onSuccess: (response) => {
void logEvent('AI Observability Attribute Mapping: Test run', {});
setTestedAttributes(submittedAttributes);
setTestedResource(submittedResource);
setResult(response.data?.spans ?? []);

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cloneDeep, isEqual } from 'lodash-es';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
useCreateSpanMapper,
useCreateSpanMapperGroup,
@@ -262,6 +263,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
void logEvent('AI Observability Attribute Mapping: Changes saved', {});
// Refresh the groups list in place — it stays mounted, so this just
// swaps in fresh data without a loading flash. Using the query's own
// refetch keeps it scoped to the groups list; the per-group mapper

View File

@@ -173,7 +173,7 @@ function Explorer(): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current) {
logEvent('Traces Explorer: Page visited', {});
logEvent('AI Observability Explorer: Page visited', {});
logEventCalledRef.current = true;
}
}, []);

View File

@@ -173,7 +173,7 @@ function ListView({
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
void logEvent('AI Observability Explorer: Data present', {
panelType,
});
}

View File

@@ -159,7 +159,7 @@ function TracesView({
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
void logEvent('AI Observability Explorer: Data present', {
panelType: 'TRACE',
});
}

View File

@@ -1,4 +1,6 @@
import { useEffect } from 'react';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
@@ -8,6 +10,10 @@ import styles from './Overview.module.scss';
function Overview(): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
useEffect(() => {
void logEvent('AI Observability Overview: Page visited', {});
}, []);
const renderContent = (): JSX.Element => {
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -1,5 +1,7 @@
import { useEffect } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
@@ -20,6 +22,10 @@ function LLMObservabilityModelPricing(): JSX.Element {
const { data } = useListUnmappedLLMModels();
const unpricedCount = data?.data?.items?.length ?? 0;
useEffect(() => {
void logEvent('AI Observability Model Pricing: Page visited', {});
}, []);
return (
<div
className={styles.llmObservabilityModelPricing}

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
@@ -94,6 +95,10 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
void logEvent('AI Observability Model Pricing: Model cost saved', {
mode,
modelName: draft.modelName,
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
getListLLMPricingRulesQueryKey,
useDeleteLLMPricingRule,
@@ -46,6 +47,9 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
}
try {
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
void logEvent('AI Observability Model Pricing: Model cost deleted', {
modelName: pendingDelete.modelName,
});
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
@@ -45,6 +46,10 @@ export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
setIsSaving(true);
try {
await createOrUpdate({ data: { rules: [payload] } });
void logEvent('AI Observability Model Pricing: Unpriced model mapped', {
modelName: model.modelName,
billingModelName: rule.modelName,
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),

View File

@@ -13,7 +13,7 @@ describe('Get Series Data', () => {
expect(seriesData).toHaveLength(5);
expect(seriesData[1].label).toBe('firstLegend');
expect(seriesData[1].show).toBe(true);
expect(seriesData[1].fill).toBe('#FF6F91');
expect(seriesData[1].fill).toBe('#83C2EB');
expect(seriesData[1].width).toBe(2);
});

View File

@@ -57,3 +57,12 @@
color: var(--l3-foreground);
text-align: center;
}
/* The bottom legend's box is only the rows reserved for it. */
.container:not(.isRight) .emptyState {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: 0;
}

View File

@@ -20,6 +20,7 @@ export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
showSearch = false,
focusedSeriesIndex,
onAction,
showCopy = true,
@@ -35,18 +36,22 @@ export default function Legend({
[items],
);
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
// The layout decides: it reserves the height.
const showToolbar = showSearch && items.length > 0;
const effectiveQuery = showFilter ? filterQuery : '';
const effectiveQuery = showToolbar ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
[items, effectiveQuery],
);
// Against the whole series set, or a search would always read "N of N".
const listedShownCount = useMemo(
() => getShownSeriesState(visibleLegendItems).visibleCount,
[visibleLegendItems],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
@@ -85,9 +90,9 @@ export default function Legend({
>
{showToolbar && (
<LegendToolbar
visibleCount={visibleCount}
visibleCount={listedShownCount}
totalCount={items.length}
showFilter={showFilter}
position={position}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>

View File

@@ -33,3 +33,32 @@
.searchIcon {
color: var(--l3-foreground);
}
/* Height + margin must match LEGEND_TOOLBAR_HEIGHT and LEGEND_TOOLBAR_GAP. */
.inlineToolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 24px;
margin-bottom: var(--spacing-2);
padding-right: var(--spacing-4);
}
.search {
flex: 0 0 auto;
width: 240px;
max-width: 100%;
}
.statusInline {
// Truncates rather than wrapping onto a row the legend has not reserved.
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.searchInputInline {
width: 100%;
height: 24px;
}

View File

@@ -1,14 +1,17 @@
import { ChangeEvent, useCallback } from 'react';
import cx from 'classnames';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import { LegendPosition } from '../types';
import styles from './LegendToolbar.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
/** Layout only: the column stacks, the bottom row does not. */
position: LegendPosition;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
@@ -17,7 +20,7 @@ export interface LegendToolbarProps {
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
position,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
@@ -27,30 +30,48 @@ export default function LegendToolbar({
[onFilterQueryChange],
);
return (
<>
{showFilter && (
const searchProps = {
allowClear: true,
prefix: <Search size={12} className={styles.searchIcon} />,
placeholder: 'Search...',
value: filterQuery,
onChange: handleFilterChange,
className: styles.searchInput,
'data-testid': 'legend-search-input',
};
const status = (
<span
className={cx(styles.status, {
[styles.statusInline]: position !== LegendPosition.RIGHT,
})}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
);
if (position === LegendPosition.RIGHT) {
return (
<>
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
<Input {...searchProps} />
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
<div className={styles.toolbar}>{status}</div>
</>
);
}
return (
<div className={styles.inlineToolbar}>
<div className={styles.search}>
<Input
{...searchProps}
className={cx(styles.searchInput, styles.searchInputInline)}
/>
</div>
</>
{status}
</div>
);
}

View File

@@ -16,6 +16,7 @@ export default function UPlotLegend({
position = LegendPosition.BOTTOM,
config,
averageLegendWidth,
showSearch,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const onAction = useLegendActions();
@@ -27,6 +28,7 @@ export default function UPlotLegend({
items={items}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onAction}
/>

View File

@@ -88,11 +88,15 @@ describe('UPlotLegend', () => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
const renderLegend = (
position?: LegendPosition,
showSearch = true,
): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
showSearch={showSearch}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
@@ -100,14 +104,38 @@ describe('UPlotLegend', () => {
);
describe('layout and position', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
'gives the legend a search box and a readout (%s)',
(position) => {
renderLegend(position);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
expect(screen.getByTestId('legend-status')).toBeInTheDocument();
},
);
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
'counts down the readout as the search narrows the list (%s)',
async (position) => {
const user = userEvent.setup();
renderLegend(position);
// B is hidden.
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
await user.type(screen.getByTestId('legend-search-input'), 'a');
// Only A matches, counted against all three.
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 1 of 3 series',
);
},
);
it('keeps a BOTTOM legend bare while every series is on screen', () => {
renderLegend(LegendPosition.BOTTOM, false);
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
@@ -116,6 +144,16 @@ describe('UPlotLegend', () => {
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('filters a BOTTOM legend from its search box', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.BOTTOM);
await user.type(screen.getByTestId('legend-search-input'), 'b');
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.queryByText('A')).not.toBeInTheDocument();
});
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);

View File

@@ -21,5 +21,9 @@ export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Must match `.inlineToolbar`'s height and margin-bottom, or it eats a row. */
export const LEGEND_TOOLBAR_HEIGHT = 24;
export const LEGEND_TOOLBAR_GAP = 4;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -73,9 +73,9 @@ function createTooltipContent(
};
}
function createUPlotInstance(cursorIdx: number | null): uPlot {
function createUPlotInstance(cursorIdx: number | null, timestamp = 1): uPlot {
return {
data: [[1], []],
data: [[timestamp], []],
cursor: { idx: cursorIdx },
// The rest of the uPlot fields are not used by Tooltip
} as unknown as uPlot;
@@ -122,6 +122,19 @@ describe('Tooltip', () => {
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('drops the date from the header title for a point on the current day', () => {
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
renderTooltip({ uPlotInstance });
const expectedTitle = dayjs(todayTimestamp * 1000)
.tz('UTC')
.format(DATE_TIME_FORMATS.TIME_SECONDS);
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('does not render header when showTooltipHeader is false', () => {
const uPlotInstance = createUPlotInstance(0);

View File

@@ -19,6 +19,7 @@ interface TooltipHeaderProps {
isPinned: boolean;
activeItem: TooltipContentItem | null;
headerRowClassName?: string;
/** Overrides the default, which drops the date part for points on the current day. */
dateFormat?: string;
}
@@ -29,7 +30,7 @@ export default function TooltipHeader({
isPinned,
activeItem,
headerRowClassName,
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
dateFormat,
}: TooltipHeaderProps): JSX.Element {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
@@ -46,9 +47,14 @@ export default function TooltipHeader({
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(dateFormat);
const pointTime = dayjs(timestamp * 1000).tz(resolvedTimezone);
const isToday = pointTime.isSame(dayjs().tz(resolvedTimezone), 'day');
return pointTime.format(
dateFormat ??
(isToday
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS),
);
}, [
resolvedTimezone,
uPlotInstance.data,

View File

@@ -145,6 +145,8 @@ export interface LegendProps {
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Set by the chart layout, which reserves the height for it. */
showSearch?: boolean;
/** Series index highlighted by the chart cursor. */
focusedSeriesIndex: number | null;
onAction: OnLegendAction;
@@ -158,6 +160,7 @@ export interface UPlotLegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
averageLegendWidth?: number;
showSearch?: boolean;
}
export interface TooltipContentItem {

View File

@@ -265,7 +265,7 @@ function getPathBuilder({
drawStyle,
lineInterpolation,
barAlignment = BarAlignment.Center,
barWidthFactor = 0.6,
barWidthFactor = 0.85,
barMaxWidth = 200,
stepInterval,
}: {

View File

@@ -297,7 +297,7 @@ describe('UPlotSeriesBuilder', () => {
);
const config = builder.getConfig();
expect(config.stroke).toBe('#E64A3C');
expect(config.stroke).toBe('#AD42E0');
});
it('passes through pointsFilter when provided', () => {

View File

@@ -1,5 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'lib/visualization/layout/ChartLayout/ChartLayout';
import ChartLayout, {
LegendLayout,
} from 'lib/visualization/layout/ChartLayout/ChartLayout';
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
import {
LegendPosition,
@@ -58,7 +60,7 @@ export default function ChartWrapper({
);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
if (!showLegend) {
return null;
}
@@ -67,6 +69,7 @@ export default function ChartWrapper({
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
/>
);
},

View File

@@ -68,17 +68,23 @@ export default function Pie({
// Reuse the uPlot chart/legend split so the donut + legend get the same area
// allocation (right column, or up-to-two bottom rows) as every other panel.
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
const {
width,
height,
legendWidth,
legendHeight,
averageLegendWidth,
showLegendSearch,
} = useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
@@ -224,6 +230,7 @@ export default function Pie({
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showLegendSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>

View File

@@ -25,6 +25,7 @@ describe('calculateChartDimensions', () => {
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
showLegendSearch: false,
});
});
@@ -106,10 +107,10 @@ describe('calculateChartDimensions', () => {
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
// clipped third row, and none left over.
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(430);
// Two 28px rows + 2px gap + 12px padding, plus the 24px search row + 4px.
expect(dims.showLegendSearch).toBe(true);
expect(dims.legendHeight).toBe(98);
expect(dims.height).toBe(402);
});
it('BOTTOM: items one past a row still reserve two rows', () => {
@@ -123,6 +124,50 @@ describe('calculateChartDimensions', () => {
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: no search row while every item is already on screen', () => {
// 1000px fits 4 per row, so 8 items fill both reserved rows exactly.
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(8),
});
expect(dims.showLegendSearch).toBe(false);
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: a search row once the grid overflows the reserved rows', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(9),
});
expect(dims.showLegendSearch).toBe(true);
expect(dims.legendHeight).toBe(98);
});
it('BOTTOM: no search row when it would push the legend past a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 120,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
expect(dims.showLegendSearch).toBe(false);
expect(dims.legendHeight).toBe(40);
});
it('RIGHT: always carries its chrome; the column has the height for it', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(40),
});
expect(dims.showLegendSearch).toBe(true);
});
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
// The item width alone suggests three fit on one row; the grid's per-item
// padding and column gap leave room for two.

View File

@@ -2,6 +2,8 @@ import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_COLUMN_GAP,
LEGEND_TOOLBAR_GAP,
LEGEND_TOOLBAR_HEIGHT,
LEGEND_ITEM_EXTRA_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
@@ -15,6 +17,8 @@ export interface ChartDimensions {
legendWidth: number;
legendHeight: number;
averageLegendWidth: number;
/** For a BOTTOM legend that row's height is inside `legendHeight`. */
showLegendSearch: boolean;
}
const AVG_CHAR_WIDTH = 8;
@@ -76,6 +80,8 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
* the rectangle never clips a row or reserves space for half of one. Two
* rows that would take half a short panel fall back to one row.
* - A grid overflowing those rows also gets a search row, whose height is
* part of `legendHeight`.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
@@ -101,6 +107,7 @@ export function calculateChartDimensions({
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
showLegendSearch: false,
};
}
@@ -140,6 +147,7 @@ export function calculateChartDimensions({
legendHeight: containerHeight,
// Single vertical list on the right.
averageLegendWidth: rightLegendWidth,
showLegendSearch: legendItemCount > 0,
};
}
@@ -158,18 +166,25 @@ export function calculateChartDimensions({
),
);
// The wrapper's bottom padding is inside this height (border-box).
const heightForRows = (rowCount: number): number =>
// The wrapper's bottom padding and the search row are inside this height.
const heightForRows = (rowCount: number, withToolbar: boolean): number =>
rowCount * LEGEND_ROW_HEIGHT +
(rowCount - 1) * LEGEND_ROW_GAP +
LEGEND_PADDING;
LEGEND_PADDING +
(withToolbar ? LEGEND_TOOLBAR_HEIGHT + LEGEND_TOOLBAR_GAP : 0);
const shortPanelBudget = containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO;
const gridRowCount = Math.ceil(legendItemCount / legendItemsPerRow);
// Only once rows overflow — below that every series is already on screen —
// and only while the row it costs leaves the legend inside the panel's share.
const showLegendSearch =
gridRowCount > LEGEND_MAX_BOTTOM_ROWS &&
heightForRows(1, true) <= shortPanelBudget;
const neededRowCount = Math.max(
1,
Math.min(
LEGEND_MAX_BOTTOM_ROWS,
Math.ceil(legendItemCount / legendItemsPerRow),
),
Math.min(LEGEND_MAX_BOTTOM_ROWS, gridRowCount),
);
// Without this, short grid panels hand most of their area to the legend and
@@ -177,11 +192,11 @@ export function calculateChartDimensions({
// row's items are clipped rather than removed, so they are scroll-only here.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
heightForRows(neededRowCount, showLegendSearch) > shortPanelBudget
? 1
: neededRowCount;
const bottomLegendHeight = heightForRows(legendRowCount);
const bottomLegendHeight = heightForRows(legendRowCount, showLegendSearch);
return {
width: containerWidth,
@@ -189,5 +204,6 @@ export function calculateChartDimensions({
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
averageLegendWidth: legendItemWidth,
showLegendSearch,
};
}

View File

@@ -7,9 +7,14 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import 'lib/visualization/layout/ChartLayout/ChartLayout.styles.scss';
export interface LegendLayout {
averageLegendWidth: number;
showSearch: boolean;
}
export interface ChartLayoutProps {
showLegend?: boolean;
legendComponent: (legendPerSet: number) => React.ReactNode;
legendComponent: (layout: LegendLayout) => React.ReactNode;
children: (props: {
chartWidth: number;
chartHeight: number;
@@ -40,6 +45,7 @@ export default function ChartLayout({
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: MAX_LEGEND_WIDTH,
showLegendSearch: false,
};
}
const legendItemsMap = config.getLegendItems();
@@ -81,7 +87,10 @@ export default function ChartLayout({
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.averageLegendWidth)}
{legendComponent({
averageLegendWidth: chartDimensions.averageLegendWidth,
showSearch: chartDimensions.showLegendSearch,
})}
</div>
)}
</div>

View File

@@ -9,6 +9,7 @@ import { authToken } from '../../helpers/common';
test.describe.configure({ mode: 'serial' });
const GROUP_NAME = 'e2e-attr-mapping-happy';
const GROUP_CONDITION = 'my_company.llm.';
const TARGET_ATTR = 'gen_ai.content.prompt';
const SOURCE_ATTR = 'my_company.llm.input';
@@ -34,6 +35,8 @@ test.describe('LLM Observability — Attribute Mapping', () => {
const groupDrawer = page.getByTestId('group-form-drawer');
await expect(groupDrawer).toBeVisible();
await page.getByTestId('group-form-name').fill(GROUP_NAME);
await page.getByTestId('group-form-attribute-add').click();
await page.getByTestId('group-form-attribute-0').fill(GROUP_CONDITION);
await page.getByTestId('group-form-save').click();
await expect(groupDrawer).toBeHidden();