Compare commits

..

5 Commits

Author SHA1 Message Date
aks07
ef3e09c0b1 feat(bottom-strip): add the layout shell behind a feature flag
Mounts a 24px strip at the bottom of the app under the same gate as the side
nav, behind the SAVED_VIEW_ENABLED localStorage flag shared with saved views.
Shows the build version on the left for now; the per-page left slot and the
right side actions come in later tickets.

To give the strip a stable box, `.app-content` becomes a column flex and
`LayoutContent` takes the height left over instead of `height: 100%`. Fixed
bottom elements read `--bottom-strip-height`, which only exists while the strip
is mounted, so with the flag off every offset falls back to where it is today.

Hides nothing. Each later ticket hides the piece it replaces.
2026-09-24 06:13:04 +00:00
Aditya Singh
720810d424 fix(quick-filters): filter sidebar scrolls the whole page (#12915)
#### Description

- Quick filters sidebar scrolls the whole page instead of scrolling
itself, so the top nav, module tabs and the table scroll away with it.
Happens on traces, llm observability, api monitoring, exceptions and
meter.. logs is the only page behaving today.
- Cause is antd Tabs.. it never passes height down to the tab pane, and
every module page wrapped `RouteTab` in a plain div, so the page inside
was never bounded. Pages that wanted their own scroll each kept a
private copy of the same `.ant-tabs` override, traces and meter never
had one.
- `RouteTab` now owns the height chain and scrolls each pane's content,
so the tab bar stays put on every tabbed page. Module pages pass their
class to `RouteTab` instead of wrapping it, and the six copied overrides
are gone.
- New `QuickFiltersLayout` gives the explorer pages a bounded two pane
layout.. 280px sidebar that scrolls itself, content that scrolls itself.
Traces, llm, api monitoring, exceptions and meter are on it now.
- Logs and infra are unchanged here, both move to the shared layout in
follow ups.
- Also drops the per page viewport heights on the quick filter settings
drawer.. with the pane bounded, `height: 100%` is enough, and the
save/discard footer stays reachable with the banners on.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/6088
Closes https://github.com/SigNoz/engineering-pod/issues/6104
Part of https://github.com/SigNoz/engineering-pod/issues/5946


#### Screenshots/ recordings



Banner fix [BEFORE]


https://github.com/user-attachments/assets/44bc98f8-7ce9-45a7-9c45-e86648c40f69

Banner fix [AFTER]



https://github.com/user-attachments/assets/c1140a2a-f00e-4685-87e4-4a0f5d72623a



Quick Filter Whole Page scroll Fix
[BEFORE]



https://github.com/user-attachments/assets/07bd42a0-db80-4d14-bf8a-bcea01fb70b1




[AFTER]




https://github.com/user-attachments/assets/7bd14951-2595-43cd-8eab-dcb57cdce011





#### Additional Information

- The `RouteTab` change touches every tabbed page, not just the quick
filter ones. Checked traces, logs, exceptions, api monitoring, meter,
infra hosts, metrics summary, funnels, saved views, pipelines, mq, logs
settings, settings (org + members) and alert details, with the trial
banner on and off. llm observability is feature gated on my instance so
it is not checked in the browser.
- Behaviour change to call out: top nav and tab bar are now fixed on all
tabbed pages. Pages that mount `RouteTab` inside a block wrapper (alert
details, the exceptions inner tabs) are unaffected, the scroller is
inert there.
- Sidebar is 280px everywhere now, was a 260/280 mix.
- Pane content that needs a bounded box should size with `height:
100%`.. `flex: 1` does nothing inside the scroller viewport (documented
on `RouteTab`).
- cc. @H4ad
2026-09-24 06:01:23 +00:00
Ashwin Bhatkal
7424885a14 chore(codeowners): add alert code ownership to pulse-frontend (#12977)
#### Description

Paths that belong to alerts and notification channels added to
CODEOWNERS

- `container/FormAlertChannels/`
- `hooks/notificationChannels/`
- `container/RoutingPolicies/`
- `components/AlertBreadcrumb/`
- `container/EditRules/`
- `components/AlertDetailsFilters/`
- `components/Alerts/`
- `hooks/routingPolicies/`
- `types/api/alerts/`
- `providers/Alert.tsx`
- `constants/alerts.ts`

All go to `@SigNoz/pulse-frontend`, matching the rest of those blocks.
2026-09-24 04:29:31 +00:00
Abhi kumar
2c09fedde1 fix(dashboard): panel UX fixes — bar gap, tooltip date, palette, legend search (#12959)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Four independent panel-UX fixes from the dashboards epic, one commit
each.

- **Bar gap.** The gap between bars was wider than half a bar. The bar
width factor goes 0.6 → 0.85, leaving just enough to separate them.
Shared by bar and histogram panels.
- **Tooltip date.** A tooltip on a point from today now shows only the
time; the date still appears for any other day.
- **Red in the palette.** Roughly one series in six was being coloured
red — 18 of 117 entries in `chartcolors`, 15 of 74 in `lightModeColor` —
which spends the one colour that should mean "something is wrong". Each
red entry is rotated onto a free hue with its original lightness
preserved and saturation clamped, and its key renamed to match. Entries
are replaced, never removed: `generateColor` indexes by `hash %
Object.keys(...).length`, so changing the count would recolour every
existing chart. Pinks and magentas are left alone.
- **Bottom legend search.** A bottom legend that overflows the rows the
panel reserves for it now gets a search box and a "Showing N of M
series" readout, on a single row above the series. Whether that row
exists is the chart layout's call, since it is the layout that reserves
the height — a row nobody reserved would eat a row of series. One
`LegendToolbar` serves both placements, with position driving the layout
only, so the right column is unchanged. The readout counts the rows the
search left listed, against the whole series set.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/3959
Closes https://github.com/SigNoz/engineering-pod/issues/3967
Closes https://github.com/SigNoz/engineering-pod/issues/3976
Closes https://github.com/SigNoz/engineering-pod/issues/3977

#### Additional Information

- The palette replacements are computed (even spread across the non-red
arc), not hand-designed — worth a visual pass across light and dark
before merging.
- Two tests asserted a palette hex for a given series label and are
updated to the new value.
- The search row's 24px height and 4px gap are pinned as
`LEGEND_TOOLBAR_HEIGHT` / `LEGEND_TOOLBAR_GAP` beside the existing row
constants; they must match the stylesheet or the reserved rectangle
clips a row.

<img width="1611" height="634" alt="image"
src="https://github.com/user-attachments/assets/2c21a458-e142-4e4f-baff-5bbcbb072140"
/>
2026-09-23 18:58:55 +00:00
Nityananda Gohain
5a1be60745 fix(ai-o11y): scope overview dashboard to gen_ai spans and move message attributes (#12967)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--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
- Top span names and Cost by service use `builder_ai_query`, so non-AI
spans no longer show up.
- LLM cost/token panels filter on `gen_ai.request.model EXISTS`; with
variables on "All" they scanned every span.
- Default span mappers now move (not copy) vendor message keys into
`gen_ai.input.messages` / `gen_ai.output.messages`, as documented.
- Bumped versions: dashboard to 3, `gen_ai.llm` mapper to 3,
`gen_ai.agent` mapper to 2.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 14:56:04 +00:00
76 changed files with 1110 additions and 691 deletions

11
.github/CODEOWNERS vendored
View File

@@ -200,6 +200,15 @@ go.mod @therealpandey
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
/frontend/src/container/RoutingPolicies/ @SigNoz/pulse-frontend
/frontend/src/components/AlertBreadcrumb/ @SigNoz/pulse-frontend
/frontend/src/container/EditRules/ @SigNoz/pulse-frontend
/frontend/src/components/AlertDetailsFilters/ @SigNoz/pulse-frontend
/frontend/src/components/Alerts/ @SigNoz/pulse-frontend
/frontend/src/hooks/routingPolicies/ @SigNoz/pulse-frontend
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -207,6 +216,8 @@ go.mod @therealpandey
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,27 +6,12 @@
left: 0;
z-index: 999;
width: 342px;
height: 100%;
background: var(--l1-background);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--l1-foreground);
&.qf-logs-explorer {
height: calc(100vh - 45px);
}
&.qf-exceptions {
height: 100vh;
}
&.qf-api-monitoring {
height: calc(100vh - 45px);
}
&.qf-traces-explorer {
height: calc(100vh - 45px);
}
&.hidden {
width: 0;
}

View File

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

View File

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

View File

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

View File

@@ -47,4 +47,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

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

View File

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

View File

@@ -53,6 +53,10 @@
z-index: 0;
background: var(--l1-background);
// Column so the bottom strip sits under the scrolling content, not inside it.
display: flex;
flex-direction: column;
&.full-screen-content {
width: 100%;
}
@@ -70,7 +74,9 @@
.chat-support-gateway {
position: fixed;
bottom: 20px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
right: 20px;
z-index: 1000;

View File

@@ -43,6 +43,7 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import BottomStrip from 'container/BottomStrip';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -51,6 +52,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -402,6 +404,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -868,6 +871,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (

View File

@@ -12,8 +12,12 @@ export const Layout = styled(LayoutComponent)`
}
`;
// Takes the height left in `.app-content` after the bottom strip.
// `min-height: 0` is not needed right now, overlayscrollbars already sets
// `overflow: auto` here. Kept so this does not break if that goes away.
export const LayoutContent = styled(LayoutComponent.Content)`
height: 100%;
flex: 1;
min-height: 0;
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -0,0 +1,40 @@
.strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
height: var(--bottom-strip-height);
padding: 0 12px;
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
// font styles
font-family: var(--font-family-sf-mono, monospace);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-none);
// Above page content, below the body-portalled overlays that are meant to
// cover the strip.
position: relative;
z-index: 1;
}
.left,
.right {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,49 @@
import { render } from 'tests/test-utils';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
describe('BottomStrip', () => {
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
`${BOTTOM_STRIP_HEIGHT}px`,
);
unmount();
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
'',
);
});
// The string is whatever the Go build injected, so it is rendered untouched —
// same as SideNav. Release tags carry the "v", local builds do not.
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
import { useLayoutEffect } from 'react';
import { useAppContext } from 'providers/App/App';
import styles from './BottomStrip.module.scss';
export const BOTTOM_STRIP_HEIGHT = 24;
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
BOTTOM_STRIP_HEIGHT_VAR,
`${BOTTOM_STRIP_HEIGHT}px`,
);
return (): void => {
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
};
}, []);
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
</div>
<div className={styles.right} />
</div>
);
}
export default BottomStrip;

View File

@@ -1,6 +1,8 @@
.create-alert-v2-footer {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: 63px;
right: 0;
background-color: var(--l1-background);

View File

@@ -1,6 +1,8 @@
.explorer-options-container {
position: fixed;
bottom: 0px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;

View File

@@ -1,6 +1,8 @@
.explorer-option-droppable-container {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: -webkit-fill-available;
height: 24px;
display: flex;

View File

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

View File

@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
<QuickFiltersLayout
className="trace-explorer-page"
data-testid="llm-observability-explorer"
testId="llm-observability-explorer"
showFilters={isOpen}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.AI_OBSERVABILITY,
signal: SignalType.AI_OBSERVABILITY,
useFieldApis: quickFiltersFieldApis,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
}}
>
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<div className="trace-explorer">
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
)}
</div>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

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

View File

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

View File

@@ -60,9 +60,6 @@
.metrics-table-container {
padding-bottom: 48px;
.ant-table {
margin-left: -16px;
margin-right: -16px;
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
@@ -184,7 +181,9 @@
.ant-pagination {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: calc(100% - 54px);
background: var(--l1-background);
padding: 16px;

View File

@@ -0,0 +1,11 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
}

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

@@ -8,7 +8,7 @@ import { LegendAction, LegendPosition, LegendProps } from '../types';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
import { getVisibleSeriesState } from './utils';
import styles from './Legend.module.scss';
@@ -20,6 +20,7 @@ export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
showSearch = false,
focusedSeriesIndex,
onAction,
showCopy = true,
@@ -30,27 +31,22 @@ export default function Legend({
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
// The layout decides: it reserves the height.
const showToolbar = showSearch && items.length > 0;
// 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;
const effectiveQuery = showToolbar ? filterQuery : '';
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
const {
listedItems,
visibleCount,
onlyVisibleSeriesIndex,
areAllSeriesVisible,
} = useMemo(
() => getVisibleSeriesState(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
const isEmptyState = !!effectiveQuery.trim() && listedItems.length === 0;
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
@@ -63,14 +59,20 @@ export default function Legend({
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isOneSeriesVisible={onlyVisibleSeriesIndex === item.seriesIndex}
areAllSeriesVisible={areAllSeriesVisible}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onAction={onAction}
/>
),
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
[
onlyVisibleSeriesIndex,
areAllSeriesVisible,
focusedSeriesIndex,
showCopy,
onAction,
],
);
return (
@@ -87,7 +89,7 @@ export default function Legend({
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
position={position}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
@@ -101,7 +103,7 @@ export default function Legend({
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
data={visibleLegendItems}
data={listedItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>
)}

View File

@@ -13,9 +13,9 @@ import styles from './LegendRow.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
isOneSeriesVisible: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
areAllSeriesVisible: boolean;
isFocused: boolean;
showCopy: boolean;
onAction: OnLegendAction;
@@ -29,15 +29,15 @@ export interface LegendRowProps {
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isOneSeriesVisible,
areAllSeriesVisible,
isFocused,
showCopy,
onAction,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const isShowAllAction = show && !areAllSeriesVisible;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
@@ -47,15 +47,15 @@ function LegendRow({
/** Everything showing -> isolate; showing alone -> restore all. */
const handleRowClick = useCallback((): void => {
if (isSoleShown) {
if (isOneSeriesVisible) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
type: areAllSeriesVisible ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
seriesIndex,
});
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
}, [isOneSeriesVisible, areAllSeriesVisible, onAction, seriesIndex]);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
@@ -126,7 +126,7 @@ function LegendRow({
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
disabled={isOneSeriesVisible}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}

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

@@ -1,6 +1,6 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
import { filterLegendItems, getVisibleSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
@@ -10,26 +10,49 @@ const items = (shown: boolean[]): LegendItem[] =>
show,
}));
describe('getShownSeriesState', () => {
describe('getVisibleSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
const state = getVisibleSeriesState(items([true, false, true]), '');
expect(state.visibleCount).toBe(2);
expect(state.onlyVisibleSeriesIndex).toBeNull();
expect(state.areAllSeriesVisible).toBe(false);
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
const state = getVisibleSeriesState(items([false, true, false]), '');
expect(state.visibleCount).toBe(1);
expect(state.onlyVisibleSeriesIndex).toBe(2);
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
const state = getVisibleSeriesState(items([false, false]), '');
expect(state.visibleCount).toBe(0);
expect(state.onlyVisibleSeriesIndex).toBeNull();
});
it('reports every series shown', () => {
expect(
getVisibleSeriesState(items([true, true]), '').areAllSeriesVisible,
).toBe(true);
});
it('counts only the series the search listed', () => {
const state = getVisibleSeriesState(items([true, true, false]), 'series-1');
expect(state.listedItems.map((item) => item.label)).toStrictEqual([
'series-1',
]);
expect(state.visibleCount).toBe(1);
});
it('reads isolation off every series, not the listed ones', () => {
const state = getVisibleSeriesState(items([false, true, false]), 'series-2');
expect(state.listedItems).toHaveLength(1);
expect(state.onlyVisibleSeriesIndex).toBe(2);
});
});

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

@@ -1,22 +1,12 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
export interface LegendViewState {
listedItems: LegendItem[];
/** Listed items that are toggled on, against every series in the readout. */
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
/** The series index when exactly one series is toggled on, else null. */
onlyVisibleSeriesIndex: number | null;
areAllSeriesVisible: boolean;
}
export function filterLegendItems(
@@ -32,3 +22,23 @@ export function filterLegendItems(
item.label?.toLowerCase().includes(normalisedQuery),
);
}
/**
* Isolation is driven by what is actually shown, never a remembered one: hiding
* series one at a time down to a single one is the same state as "Only". It is
* read off the whole series set, not off what the search left listed.
*/
export function getVisibleSeriesState(
items: LegendItem[],
query: string,
): LegendViewState {
const visible = items.filter((item) => item.show);
const listedItems = filterLegendItems(items, query);
return {
listedItems,
visibleCount: listedItems.filter((item) => item.show).length,
onlyVisibleSeriesIndex: visible.length === 1 ? visible[0].seriesIndex : null,
areAllSeriesVisible: visible.length === items.length,
};
}

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

@@ -1,11 +1,10 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Pin } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
@@ -19,6 +18,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 +29,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 +46,11 @@ export default function TooltipHeader({
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(dateFormat);
return formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
);
}, [
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

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
display: flex;
flex-direction: column;
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: 0;
width: 100%;
z-index: 100;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { formatTimestampOmittingTodaysDate } from '../timeUtils';
describe('formatTimestampOmittingTodaysDate', () => {
const timezone = 'Asia/Kolkata';
it('drops the date for a point on the current day', () => {
const now = dayjs().tz(timezone);
expect(formatTimestampOmittingTodaysDate(now.valueOf(), timezone)).toBe(
now.format(DATE_TIME_FORMATS.TIME_SECONDS),
);
});
it('keeps the date for a point on any other day', () => {
const yesterday = dayjs().tz(timezone).subtract(1, 'day');
expect(formatTimestampOmittingTodaysDate(yesterday.valueOf(), timezone)).toBe(
yesterday.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS),
);
});
it('honours an explicit format over the day check', () => {
const now = dayjs().tz(timezone);
expect(
formatTimestampOmittingTodaysDate(
now.valueOf(),
timezone,
DATE_TIME_FORMATS.ISO_DATETIME_SECONDS,
),
).toBe(now.format(DATE_TIME_FORMATS.ISO_DATETIME_SECONDS));
});
});

View File

@@ -342,3 +342,22 @@ export const getMs = (value: string): string =>
})
.format('SSS'),
).toFixed(2);
/** `overrideFormat`, when given, wins over the same-day check. */
export const formatTimestampOmittingTodaysDate = (
timestampMs: number,
timezone: string,
overrideFormat?: string,
): string => {
const time = dayjs(timestampMs).tz(timezone);
if (overrideFormat) {
return time.format(overrideFormat);
}
return time.format(
time.isSame(dayjs().tz(timezone), 'day')
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
);
};

View File

@@ -1,5 +1,5 @@
{
"version": 2,
"version": 3,
"definition": {
"schemaVersion": "v6",
"name": "signoz---ai-o11y-overview",
@@ -437,7 +437,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -489,7 +489,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -722,7 +722,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -782,7 +782,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -850,7 +850,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -868,7 +868,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -886,7 +886,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -904,7 +904,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -961,7 +961,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -979,7 +979,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -997,7 +997,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -1015,7 +1015,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -1076,7 +1076,7 @@
"stepInterval": 0,
"disabled": true,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -1175,7 +1175,7 @@
"stepInterval": 0,
"disabled": true,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -1192,7 +1192,7 @@
"stepInterval": 0,
"disabled": true,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -1248,7 +1248,7 @@
"spec": {
"queries": [
{
"type": "builder_query",
"type": "builder_ai_query",
"spec": {
"name": "A",
"signal": "traces",
@@ -1282,7 +1282,7 @@
}
},
{
"type": "builder_query",
"type": "builder_ai_query",
"spec": {
"name": "B",
"signal": "traces",
@@ -1353,7 +1353,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -2075,7 +2075,7 @@
"stepInterval": 0,
"disabled": false,
"filter": {
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
},
"aggregations": [
{
@@ -2724,7 +2724,7 @@
"spec": {
"queries": [
{
"type": "builder_query",
"type": "builder_ai_query",
"spec": {
"name": "A",
"signal": "traces",

View File

@@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"definition": {
"name": "gen_ai.agent",
"condition": {
@@ -68,7 +68,7 @@
{
"key": "final_result",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 10
}
]

View File

@@ -1,5 +1,5 @@
{
"version": 2,
"version": 3,
"definition": {
"name": "gen_ai.llm",
"condition": {
@@ -250,19 +250,19 @@
{
"key": "gen_ai.prompt",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 30
},
{
"key": "ai.prompt.messages",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 20
},
{
"key": "input.value",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 10
}
]
@@ -276,25 +276,25 @@
{
"key": "gen_ai.completion",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 30
},
{
"key": "ai.response.toolCalls",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 25
},
{
"key": "ai.response.text",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 20
},
{
"key": "output.value",
"context": "attribute",
"operation": "copy",
"operation": "move",
"priority": 10
}
]

View File

@@ -105,17 +105,6 @@ func (m *Manager) RecommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte
configId string,
err error,
) {
return m.recommendAgentConfig(orgId, currentConfYaml, true)
}
// Implements opamp.AgentConfigProvider
func (m *Manager) PreviewAgentConfig(orgId valuer.UUID, currentConfYaml []byte) ([]byte, error) {
recommendation, _, err := m.recommendAgentConfig(orgId, currentConfYaml, false)
return recommendation, err
}
func (m *Manager) recommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte, recordDeployment bool) ([]byte, string, error) {
var configId string
recommendation := currentConfYaml
settingVersionsUsed := []string{}
@@ -145,9 +134,6 @@ func (m *Manager) recommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte
settingVersionsUsed = append(settingVersionsUsed, configId)
if !recordDeployment {
continue
}
_ = m.updateDeployStatus(
context.Background(),
orgId,

View File

@@ -239,7 +239,7 @@ func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.Get
},
Config: []pipelinetypes.PipelineOperator{
{
ID: "normalize_body_default",
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",

View File

@@ -128,12 +128,6 @@ func (ta *MockAgentConfigProvider) HasReportedDeploymentStatus(orgID valuer.UUID
return exists
}
// AgentConfigProvider interface
func (ta *MockAgentConfigProvider) PreviewAgentConfig(orgId valuer.UUID, baseConfYaml []byte) ([]byte, error) {
recommendedYaml, _, err := ta.RecommendAgentConfig(orgId, baseConfYaml)
return recommendedYaml, err
}
// AgentConfigProvider interface
func (ta *MockAgentConfigProvider) GetDeployStatusByHash(_ context.Context, _ valuer.UUID, _ string) (opamptypes.DeployStatus, error) {
return opamptypes.DeployStatusUnknown, nil

View File

@@ -24,8 +24,6 @@ type Agent struct {
remoteConfig *protobufs.AgentRemoteConfig
Status *protobufs.AgentToServer
reconnectConfigChecked bool
// can this agent be load balancer
CanLB bool
@@ -293,11 +291,6 @@ func (agent *Agent) processStatusUpdate(
// We need to recalculate the config.
configChanged = agent.updateRemoteConfig(configProvider)
} else if agent.remoteConfig == nil && !agent.reconnectConfigChecked && agent.Config != "" {
// A running agent reconnected after a server restart; settings may have
// changed while it was away (e.g. startup reconciliation).
agent.reconnectConfigChecked = true
configChanged = agent.updateRemoteConfigIfStale(configProvider)
}
// If remote config is changed and different from what the Agent has then
@@ -319,20 +312,6 @@ func (agent *Agent) processStatusUpdate(
}
}
// updateRemoteConfigIfStale records a deployment only when the recommendation
// differs from the agent's effective config.
func (agent *Agent) updateRemoteConfigIfStale(configProvider AgentConfigProvider) bool {
recommendedConfig, err := configProvider.PreviewAgentConfig(agent.OrgID, []byte(agent.Config))
if err != nil {
agent.logger.Error("could not preview config recommendation for agent", "agent_id", agent.AgentID, errors.Attr(err))
return false
}
if string(recommendedConfig) == agent.Config {
return false
}
return agent.updateRemoteConfig(configProvider)
}
func (agent *Agent) updateRemoteConfig(configProvider AgentConfigProvider) bool {
recommendedConfig, confId, err := configProvider.RecommendAgentConfig(agent.OrgID, []byte(agent.Config))
if err != nil {

View File

@@ -18,10 +18,6 @@ type AgentConfigProvider interface {
err error,
)
// PreviewAgentConfig returns the config RecommendAgentConfig would, without
// recording a deployment.
PreviewAgentConfig(orgId valuer.UUID, currentConfYaml []byte) ([]byte, error)
// Report deployment status for config recommendations generated by RecommendAgentConfig
ReportConfigDeploymentStatus(
orgId valuer.UUID,