Compare commits

..

10 Commits

Author SHA1 Message Date
aks07
89666ef2d9 chore(explorer): remove an inaccurate comment 2026-10-08 21:14:21 +05:30
aks07
41ad49d21d fix(llm-observability): run the orderBy sanitizer on the explorer route 2026-10-08 21:14:21 +05:30
Aditya Singh
9e8b1530fd Merge branch 'main' into fix/explorer-list-order-by 2026-10-08 18:37:10 +05:30
aks07
9e2ac4c55f fix(llm-observability): keep the list order-by in the composite query
Apply the same change as the logs and traces explorers to the list and trace
views, which are a copy of them. The trace view keeps its own default of
last_activity_time:desc.
2026-10-08 18:20:37 +05:30
aks07
a0960fe862 fix(explorer): keep the list order-by in the composite query
The order lived in component state, so it never reached the URL and was lost
on back navigation, ignored on a deep link, and missing from a saved view. It
now lives in the query and is written through redirectWithQueryBuilderData.

A raw panel cannot order by an aggregation, so getRawPanelOrderBy drops those
on read and leaves them in the query for the aggregated tab to use.
2026-10-08 18:20:35 +05:30
aks07
cd1a88fd7d fix(explorer): clear the run-query bypass after the init it applies to
handleRunQuery sets a flag so the init it triggers keeps the query exactly as
built. The flag was cleared only when the pathname changed, so after one run
every later init on that page skipped sanitizing too: tab switches, filters,
time range changes and saved views. A refresh reset it, a tab switch did not.

Cmd+Enter in the filter bar also sets it, so most sessions entered that state
without using Stage and Run.
2026-10-08 18:20:30 +05:30
aks07
0b6a6134d2 fix(explorer): stop the orderBy sanitizer running on list panels
The sanitizer keeps only order-by columns that are a group-by key or an
aggregation. A list panel has neither, so it dropped every valid row-field
sort. Gate it on panel type, and keep it for the aggregated panels it was
written for.
2026-10-08 18:20:17 +05:30
Ashwin Bhatkal
04db70bade feat(infra-monitoring): add the overview tab to the kubernetes side drawer (#12948)
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
#### Description

- Adds an **Overview** tab to the Kubernetes side drawer. It lists
resources related to the drawer's entity, using the existing v2
infra-monitoring list APIs (`listPods`, `listContainers`, …) and reusing
each category's table columns and fetcher from the entity registry.
- The drawer entity's identity expression (e.g. `k8s.node.name = 'xyz'`)
is locked into every related query as the search's initial expression,
so it cannot be edited away. Anything the user types is added on top.
- A dropdown switches the listed resource (defaults to pods), and a
compass button opens the same list, with the same filter, on the
corresponding k8s list page.
- Clicking a row opens that resource's own drawer, overview tab
included, so you can walk node -> pod -> container.
- Overview is the landing tab for every category that has related
resources; categories without any (volumes, containers) are unchanged
and still land on Metrics.

#### Screen Recording


https://github.com/user-attachments/assets/2bfdf320-0267-4552-b7c2-1eb54451256d

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/512
Closes https://github.com/SigNoz/pulse-pod/issues/513
2026-10-08 08:40:44 +00:00
Abhi kumar
54aaad2cc3 fix(dashboard): heatmap legend search and two-row cap (#13082)
#### Description

- Heatmap legend now shows the search bar and caps at two rows, like
other panels. The layout already reserved the search row, but
`customLegend` only received the item width, so the heatmap never
rendered it and its grid spilled into that space as a third row.
`customLegend` now gets the full `LegendLayout`.
- Legend readouts take an optional `itemLabel` (default `series`);
heatmap passes `groups`, since its entries are summed into one grid
rather than drawn as separate series.

#### Screenshots
Before
<img width="987" height="436" alt="image"
src="https://github.com/user-attachments/assets/f4ec5170-dc11-46c4-8de3-452cbc1fb094"
/>

After
<img width="984" height="429" alt="image"
src="https://github.com/user-attachments/assets/f64aea62-f167-4151-a060-c0e2bf603aa1"
/>


Closes https://github.com/SigNoz/pulse-pod/issues/532
2026-10-08 07:35:06 +00:00
Ashwin Bhatkal
c407456be7 fix(new-select): reveal truncated values and contain dropdown scroll (#13063)
#### Description

- Truncated option labels in `CustomSelect` and `CustomMultiSelect` show
the full value in a tooltip on hover. The tooltip appears only when the
label is actually cut off.
- The selected value in a closed `CustomSelect` shows the same tooltip
when truncated.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/525


#### Screenshots

<img width="242" height="183" alt="Screenshot 2026-10-07 at 2 13 03 PM"
src="https://github.com/user-attachments/assets/e5397b1f-c305-4e9b-902d-fd73af69e3ba"
/>
<img width="375" height="168" alt="Screenshot 2026-10-07 at 3 17 18 PM"
src="https://github.com/user-attachments/assets/8807220a-5822-4198-b31d-787fa22ffb5d"
/>
<img width="616" height="285" alt="Screenshot 2026-10-07 at 2 13 23 PM"
src="https://github.com/user-attachments/assets/fb115f10-8190-4d82-8187-1ba7343ee7a2"
/>
<img width="579" height="481" alt="Screenshot 2026-10-07 at 2 12 49 PM"
src="https://github.com/user-attachments/assets/55c28a24-e3ab-4626-84f5-654c75e3eb96"
/>
2026-10-08 04:25:58 +00:00
88 changed files with 3594 additions and 504 deletions

View File

@@ -21,7 +21,6 @@ import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -30,6 +29,7 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
@@ -729,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
};
return (
<div
<TruncatedTooltip
key={option.value || `option-${index}`}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
title={String(option.label || '')}
>
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<Typography.Text truncate={1} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
</div>
</Checkbox>
</div>
</Checkbox>
</div>
)}
</TruncatedTooltip>
);
},
[

View File

@@ -16,6 +16,7 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -25,6 +26,7 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const optionId = `option-${index}`;
return (
<div
key={option.value}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
)}
</TruncatedTooltip>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
const optionsWithHighlight = useMemo(() => {
const matching =
options?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
) ?? [];
// antd shows a value with no matching option as a bare string, so keep the
// selected value listed to render it through the tooltip label below.
return (
isEmpty(value)
? matching
: prioritizeOrAddOptionForSingleSelect(matching, value)
).map((option) => ({
...option,
// Rendered as the selected value in the closed control.
label: (
<TruncatedTooltip title={String(option.label || '')} side="bottom">
{(textRef): JSX.Element => (
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
)}
</TruncatedTooltip>
),
}));
}, [options, searchText, value, highlightMatchedText]);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
// Self-provided so label tooltips work wherever this select is rendered.
<TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,4 @@
// Above the antd select popup it is shown from.
.tooltipContent {
--tooltip-z-index: 2100;
}

View File

@@ -0,0 +1,41 @@
import { ReactElement, RefObject, useRef, useState } from 'react';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import styles from './TruncatedTooltip.module.scss';
interface TruncatedTooltipProps {
title: string;
side?: TooltipSimpleProps['side'];
/** Renders the trigger; attach the ref to the element that may truncate. */
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
}
function TruncatedTooltip({
title,
side = 'right',
children,
}: TruncatedTooltipProps): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean): void => {
const el = textRef.current;
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
};
return (
<TooltipSimple
title={title}
side={side}
arrow
delayDuration={300}
open={open}
onOpenChange={handleOpenChange}
tooltipContentProps={{ className: styles.tooltipContent }}
>
{children(textRef)}
</TooltipSimple>
);
}
export default TruncatedTooltip;

View File

@@ -0,0 +1,34 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
describe('CustomSelect selected value tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it.each([
['listed in the options', [{ label: LABEL, value: LABEL }]],
['missing from the options', [{ label: 'other', value: 'other' }]],
])('reveals a truncated selected value %s on hover', async (_, options) => {
render(<CustomSelect options={options} value={LABEL} />);
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(screen.getByText(LABEL));
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
});

View File

@@ -0,0 +1,63 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import TruncatedTooltip from '../TruncatedTooltip';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
function renderRow(scrollWidth: number, clientWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockReturnValue(scrollWidth);
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockReturnValue(clientWidth);
render(
<TooltipProvider>
<TruncatedTooltip title={LABEL}>
{(textRef): JSX.Element => (
<div data-testid="row">
<span ref={textRef}>{LABEL}</span>
</div>
)}
</TruncatedTooltip>
</TooltipProvider>,
);
}
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('TruncatedTooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reveals truncated text when its row is hovered', async () => {
renderRow(400, 200);
await hover(screen.getByTestId('row'));
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
it('shows no tooltip when the text fits', async () => {
renderRow(200, 200);
await hover(screen.getByTestId('row'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -135,6 +135,51 @@ const LONG_LABEL_OPTION = {
value: 'checkout-payment-authorisation',
};
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
export const TruncatedOption: Story = {
args: { options: [LONG_LABEL_OPTION, ...options] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: hovering a cut-off selected value reveals it in full. */
export const TruncatedValue: Story = {
args: {
options: [LONG_LABEL_OPTION, ...options],
value: LONG_LABEL_OPTION.value,
},
play: async (): Promise<void> => {
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: a multi-select option reveals its full label the same way. */
export const MultiTruncatedOption: Story = {
render: (): JSX.Element => (
<CustomMultiSelect
aria-label="Services"
options={[LONG_LABEL_OPTION, ...options]}
/>
),
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Services' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given

View File

@@ -29,6 +29,13 @@ $custom-border-color: #2c3044;
background-color: var(--l2-background);
color: color-mix(in srgb, var(--border) 70%, transparent);
}
// The search input overlays the selected value; lift the value above it while
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
&:not(.ant-select-open) .ant-select-selection-item {
position: relative;
z-index: 1;
}
}
// Keep chip styles ONLY in the multi-select
@@ -223,6 +230,33 @@ $custom-border-color: #2c3044;
}
}
// Ellipsized option label; also the selected value in the closed single select.
.option-label-text {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
.custom-select-dropdown,
.custom-multiselect-dropdown {
position: relative;
overscroll-behavior: contain;
&::after {
content: '';
position: absolute;
top: 100%;
left: 0;
width: 1px;
height: 1px;
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;

View File

@@ -5,7 +5,7 @@ import {
InframonitoringtypesHostRecordDTO,
InframonitoringtypesHostStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';

View File

@@ -2,6 +2,13 @@
display: flex;
align-items: center;
gap: var(--spacing-5);
// A block span leaves the icon on the text baseline, a couple of pixels
// above the centre of the row it sits in
[data-slot='icon'] {
display: inline-flex;
align-items: center;
}
}
.infoIcon {

View File

@@ -0,0 +1,78 @@
import { rest } from 'msw';
import { toggleControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
export const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
const POD_NAME = 'app-demo-loadgenerator-579fbdd749-q5kgd';
export const NODE_NAME = 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7';
const POD_META = {
'k8s.pod.uid': POD_UID,
'k8s.pod.name': POD_NAME,
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': NODE_NAME,
'k8s.deployment.name': 'app-demo-loadgenerator',
};
const podRecord = {
podUID: POD_UID,
podCPU: 0.92,
podCPURequest: 31,
podCPULimit: 30.7,
podMemory: 148 * 1024 * 1024,
podMemoryRequest: 42,
podMemoryLimit: 38,
podStatus: 'running',
podRestarts: 0,
podAge: 49_000_000,
meta: POD_META,
};
const container = {
containerName: 'loadgenerator',
podName: POD_NAME,
status: 'running',
ready: true,
restarts: 0,
meta: POD_META,
};
const list = (records: unknown[]): Record<string, unknown> => ({
status: 'success',
data: { type: 'list', records, total: records.length },
});
export const drawerMocks = defineStoryMocks({
controls: {
openedFromANode: toggleControl('Opened from a node', {
group: 'Drawer',
value: true,
}),
},
handlers: () => [
rest.post('http://localhost/api/v2/infra_monitoring/pods', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(list([podRecord]))),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/kube_containers',
(_req, res, ctx) => res(ctx.status(200), ctx.json(list([container]))),
),
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
],
});

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podEntityConfig } from '../Pods/entity.config';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import K8sBaseDetails from './K8sBaseDetails';
import {
drawerMocks,
NODE_NAME,
POD_UID,
} from './K8sBaseDetails.stories.mocks';
import type { K8sBaseDetailsProps } from './types';
import {
getDrawerHistoryDepth,
pushDrawerHistory,
resetDrawerHistory,
} from './useDrawerHistoryStore';
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
K8sBaseDetailsProps<unknown>;
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
const meta = {
title: 'Infra monitoring/Kubernetes drawer',
component: K8sBaseDetails as (props: DrawerArgs) => JSX.Element,
...storyMocks(drawerMocks, { route }),
decorators: [
(Story, context): JSX.Element => {
// The back control appears only for a resource opened from another
const depth = getDrawerHistoryDepth();
if (context.args.openedFromANode && depth === 0) {
pushDrawerHistory({
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
entity: { nodeName: NODE_NAME },
queryKeyPrefix: 'node',
});
}
if (!context.args.openedFromANode && depth > 0) {
resetDrawerHistory();
}
return <Story />;
},
],
} satisfies Meta<DrawerArgs>;
export default meta;
type Story = StoryObj<DrawerArgs>;
/** A pod opened from a node's overview tab, so the header leads with the way back. */
export const Default: Story = {
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { X } from '@signozhq/icons';
import { ArrowLeft, X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
@@ -16,12 +16,19 @@ import {
useGlobalTimeStore,
} from 'store/globalTime';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8S_CATEGORY_SINGULAR_LABELS,
} from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { CategoryIcon } from './categoryIcons';
import { getEntityNameAttributeKey } from './relations';
import { useDrawerHistoryStore } from './useDrawerHistoryStore';
import { usePrimeEntityDetails } from './usePrimeEntityDetails';
import { K8sBaseDetailsProps } from './types';
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
@@ -86,6 +93,11 @@ export default function K8sBaseDetails<T>({
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const selectedItemExpression = useMemo(
() => getSelectedItemExpression(selectedItemParams),
[getSelectedItemExpression, selectedItemParams],
);
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
@@ -121,9 +133,10 @@ export default function K8sBaseDetails<T>({
const { minTime, maxTime } = getMinMaxTime();
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const expression = getSelectedItemExpression(selectedItemParams);
return fetchEntityData({ filter: { expression }, start, end }, signal);
return fetchEntityData(
{ filter: { expression: selectedItemExpression }, start, end },
signal,
);
},
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
@@ -158,9 +171,29 @@ export default function K8sBaseDetails<T>({
}
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
const previousEntry = useDrawerHistoryStore(
(store) => store.entries[store.entries.length - 1] ?? null,
);
const popDrawerHistory = useDrawerHistoryStore((store) => store.pop);
const primeDetails = usePrimeEntityDetails();
const resetHistory = useDrawerHistoryStore((store) => store.reset);
const handleClose = useCallback((): void => {
resetHistory();
setSelectedItemParams(null);
}, [setSelectedItemParams]);
}, [resetHistory, setSelectedItemParams]);
// Steps back through resources opened from an overview tab
const handleBack = useCallback((): void => {
const previous = popDrawerHistory();
if (!previous) {
return;
}
primeDetails(previous);
setSelectedItemParams(previous.params);
}, [popDrawerHistory, primeDetails, setSelectedItemParams]);
const handleOpenChange = useCallback(
(open: boolean): void => {
@@ -172,11 +205,25 @@ export default function K8sBaseDetails<T>({
);
const handleCopyId = useCallback((): void => {
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}, []);
toast.success(
`${K8S_CATEGORY_SINGULAR_LABELS[category]} name copied to clipboard`,
{ position: 'bottom-left' },
);
}, [category]);
const entityName = entity ? getEntityName(entity) : '';
// meta carries the resource's parents; its own name comes from the config
const entityAttributes = useMemo((): Record<string, string> => {
const meta = (entity as { meta?: Record<string, string> | null } | null)
?.meta;
return {
...(meta ?? {}),
...(entityName ? { [getEntityNameAttributeKey(category)]: entityName } : {}),
};
}, [entity, entityName, category]);
useEffect(() => {
if (entity) {
void logEvent(InfraMonitoringEvents.PageVisited, {
@@ -187,20 +234,48 @@ export default function K8sBaseDetails<T>({
}
}, [entity, eventCategory]);
// Names the resource it returns to, so a drill several levels deep stays clear
const backTarget = previousEntry
? `Back to ${K8S_CATEGORY_SINGULAR_LABELS[previousEntry.category]}`
: '';
const backLabel = previousEntry ? `${backTarget}: ${previousEntry.label}` : '';
// TODO(H4ad): Improve this on component level
// DrawerWrapper types `title` as string but renders any ReactNode.
const drawerTitle = (
<>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
{/* One control: step back where there is a trail, close otherwise.
Clicking outside the drawer closes it either way. The label is text
rather than a tooltip, which a click on the control would dismiss. */}
{previousEntry ? (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleBack}
data-testid="drawer-back-button"
className={styles.backButton}
aria-label={backLabel}
prefix={<ArrowLeft size={14} />}
>
{backTarget}
</Button>
) : (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleClose}
data-testid="close-drawer-button"
className={styles.closeButton}
prefix={<X />}
/>
)}
<Divider type="vertical" />
<span className={styles.entityKind} data-testid="drawer-entity-kind">
<CategoryIcon category={category} />
{K8S_CATEGORY_SINGULAR_LABELS[category]}
</span>
<Typography.Text className={styles.title}>
{entityName ||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
@@ -208,8 +283,8 @@ export default function K8sBaseDetails<T>({
'-'}
</Typography.Text>
<CopyButton
value={selectedItem ?? ''}
ariaLabel="Copy ID"
value={entityName || selectedItem || ''}
ariaLabel={`Copy ${K8S_CATEGORY_SINGULAR_LABELS[category]} name`}
className={styles.copyIdButton}
testId="copy-id-button"
onCopy={handleCopyId}
@@ -271,6 +346,8 @@ export default function K8sBaseDetails<T>({
customTabs={customTabs}
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
eventsInitialExpression={eventsInitialExpression}
entityAttributes={entityAttributes}
entityName={entityName}
/>
</GlobalTimeProvider>
)}

View File

@@ -4,6 +4,7 @@ import {
ChevronsLeftRight,
Compass,
DraftingCompass,
LayoutGrid,
ScrollText,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
@@ -31,6 +32,7 @@ import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityOverview from '../EntityDetailsUtils/EntityOverview/EntityOverview';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
@@ -67,12 +69,16 @@ export default function K8sBaseDetailsContent<T>({
customTabs,
logsAndTracesInitialExpression,
eventsInitialExpression,
entityAttributes,
entityName,
}: K8sBaseDetailsContentProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const tabVisibility = useMemo(
() => ({
// Declared per category, since only a kubernetes resource relates to others
showOverview: false,
showMetrics: true,
showLogs: true,
showTraces: true,
@@ -86,6 +92,9 @@ export default function K8sBaseDetailsContent<T>({
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showOverview) {
tabs.push(VIEW_TYPES.OVERVIEW);
}
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
@@ -104,14 +113,28 @@ export default function K8sBaseDetailsContent<T>({
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
const defaultView = validTabs[0] || VIEW_TYPES.METRICS;
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
useEffect(() => {
if (
!hideDetailViewTabs &&
selectedView &&
!validTabs.includes(selectedView)
) {
void setSelectedView(defaultView);
}
}, [
hideDetailViewTabs,
selectedView,
validTabs,
defaultView,
setSelectedView,
]);
// An absent view param lands on the first tab the category offers
const effectiveView = hideDetailViewTabs
? VIEW_TYPES.METRICS
: (selectedView ?? defaultView);
// Wait until the view is a valid tab so we don't log a pending value before
// the validation effect above corrects an out-of-scope query param
@@ -169,20 +192,20 @@ export default function K8sBaseDetailsContent<T>({
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
view: effectiveView,
});
logInfraExplorerNavigatedEvent({
entityType: category,
destination:
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
effectiveView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
source: 'tab_cta_button',
tab: selectedView,
tab: effectiveView,
sourceKey: null,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
if (selectedView === VIEW_TYPES.LOGS) {
if (effectiveView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
@@ -207,7 +230,7 @@ export default function K8sBaseDetailsContent<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
} else if (effectiveView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
@@ -264,7 +287,7 @@ export default function K8sBaseDetailsContent<T>({
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
entityType={category}
activeTab={selectedView}
activeTab={effectiveView}
/>
)}
</div>
@@ -275,8 +298,21 @@ export default function K8sBaseDetailsContent<T>({
type="single"
className={styles.viewsTabs}
onChange={handleTabChange}
value={selectedView}
value={effectiveView}
items={[
...(tabVisibility.showOverview
? [
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className={styles.viewTitle}>
<LayoutGrid size={14} />
Overview
</div>
),
},
]
: []),
...(tabVisibility.showMetrics
? [
{
@@ -341,7 +377,7 @@ export default function K8sBaseDetailsContent<T>({
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
{effectiveView === VIEW_TYPES.LOGS && (
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
<Button
variant="ghost"
@@ -354,7 +390,7 @@ export default function K8sBaseDetailsContent<T>({
</Button>
</TooltipSimple>
)}
{selectedView === VIEW_TYPES.TRACES && (
{effectiveView === VIEW_TYPES.TRACES && (
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
<Button
variant="ghost"
@@ -370,6 +406,16 @@ export default function K8sBaseDetailsContent<T>({
</div>
)}
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
<EntityOverview
category={category}
eventEntity={eventCategory}
attributes={entityAttributes}
entity={entity}
entityName={entityName}
queryKeyPrefix={queryKeyPrefix}
/>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
@@ -406,7 +452,7 @@ export default function K8sBaseDetailsContent<T>({
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
effectiveView === tab.key ? (
<Fragment key={tab.key}>
{tab.render({
entity,

View File

@@ -39,6 +39,7 @@ import K8sOptionsSidePanel from './K8sOptionsSidePanel';
import K8sHeader from './K8sHeader';
import { K8sPaginationWarning } from './K8sPaginationWarning';
import K8sTableToolbar from './K8sTableToolbar';
import { resetDrawerHistory } from './useDrawerHistoryStore';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
@@ -301,6 +302,8 @@ export function K8sBaseList<
queryClient.setQueryData(detailQueryKey, { data: record });
}
// Opening from the list starts a fresh trail for the drawer's back control
resetDrawerHistory();
setSelectedItemParams(params);
}

View File

@@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { useInfraMonitoringCategory } from '../hooks';
import {
useInfraMonitoringCategory,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { getEntityConfig } from './entity.registry';
import { K8sBaseList } from './K8sBaseList';
import K8sBaseDetails from './K8sBaseDetails';
@@ -15,27 +18,35 @@ export function K8sDynamicList({
leftFilters,
}: K8sDynamicListProps): JSX.Element | null {
const [selectedCategory] = useInfraMonitoringCategory();
const [selectedItemParams] = useInfraMonitoringSelectedItemParams();
const config = useMemo(
() => getEntityConfig(selectedCategory),
[selectedCategory],
);
// A row opened from the drawer's overview tab belongs to another category
const drawerCategory = selectedItemParams.category ?? selectedCategory;
const drawerConfig = useMemo(
() => getEntityConfig(drawerCategory),
[drawerCategory],
);
if (!config) {
return null;
}
const { list, details } = config;
return (
<>
<K8sBaseList
{...list}
{...config.list}
controlListPrefix={controlListPrefix}
leftFilters={leftFilters}
/>
<K8sBaseDetails {...details} />
{/* No key: remounting would animate the drawer shut and open again
for what is really a change of contents */}
{drawerConfig && <K8sBaseDetails {...drawerConfig.details} />}
</>
);
}

View File

@@ -30,6 +30,7 @@ import {
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { resetDrawerHistory } from './useDrawerHistoryStore';
import { K8sBaseFilters } from './types';
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
@@ -219,6 +220,8 @@ export function K8sExpandedRow<
queryClient.setQueryData(detailQueryKey, { data: row });
}
// Opening from the list starts a fresh trail for the drawer's back control
resetDrawerHistory();
setSelectedItemParams(params);
},
[

View File

@@ -0,0 +1,152 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfraMonitoringEvents } from 'constants/events';
import {
NuqsTestingAdapter,
type OnUrlUpdateFunction,
} from 'nuqs/adapters/testing';
import { act, render, waitFor } from 'tests/test-utils';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
import K8sBaseDetails from '../K8sBaseDetails';
import { useDrawerHistoryStore } from '../useDrawerHistoryStore';
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
type TestEntity = { name: string };
const mockEntity: TestEntity = { name: 'test-container' };
const NODE_PARAMS = {
selectedItem: 'node-1',
category: InfraMonitoringEntity.NODES,
clusterName: null,
namespaceName: null,
};
/** What the drawer records before opening a resource from the node's overview */
const NODE_ENTRY = {
params: NODE_PARAMS,
category: InfraMonitoringEntity.NODES,
label: 'node-1',
entity: { name: 'node-1' },
queryKeyPrefix: 'node',
};
function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
render(
<NuqsTestingAdapter
searchParams={{
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'container-1',
}}
onUrlUpdate={onUrlUpdate}
>
<K8sBaseDetails<TestEntity>
category={InfraMonitoringEntity.CONTAINERS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
fetchEntityData={jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null })}
getEntityName={(entity): string => entity.name}
getInitialLogTracesExpression={(): string => ''}
getInitialEventsExpression={(): string => ''}
metadataConfig={[
{ label: 'Name', getValue: (entity): string => entity.name },
]}
entityWidgetInfo={[{ title: 'CPU', yAxisUnit: 'percent' }]}
getEntityQueryPayload={jest.fn().mockReturnValue([])}
queryKeyPrefix="testContainer"
/>
</NuqsTestingAdapter>,
);
}
describe('K8sBaseDetails - back navigation', () => {
beforeEach(() => {
useDrawerHistoryStore.getState().reset();
});
it('closes rather than going back when opened from the list', async () => {
act(() => {
renderDrawer();
});
await waitFor(() => {
expect(screen.getAllByText('test-container').length).toBeGreaterThan(0);
});
expect(screen.queryByTestId('drawer-back-button')).not.toBeInTheDocument();
expect(screen.getByTestId('close-drawer-button')).toBeInTheDocument();
});
it('replaces the close control while there is somewhere to go back to', async () => {
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer();
});
const backButton = await screen.findByTestId('drawer-back-button');
// The destination is text rather than a tooltip, which a click would dismiss
expect(backButton).toHaveTextContent('Back to Node');
expect(backButton).toHaveAccessibleName('Back to Node: node-1');
expect(screen.queryByTestId('close-drawer-button')).not.toBeInTheDocument();
});
it('returns to the resource this one was opened from', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn();
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer(onUrlUpdate);
});
const backButton = await screen.findByTestId('drawer-back-button');
await user.click(backButton);
await waitFor(() => {
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
expect(
params.some(
(searchParams) =>
searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM) ===
'node-1' &&
searchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY,
) === InfraMonitoringEntity.NODES,
),
).toBe(true);
});
// The trail is one step shorter, so a second back closes instead
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
it('drops the trail when the drawer is dismissed', async () => {
const user = userEvent.setup();
useDrawerHistoryStore.getState().push(NODE_ENTRY);
act(() => {
renderDrawer();
});
await screen.findByTestId('drawer-back-button');
// Dismissing from outside the drawer, the way a click on the overlay does
await user.keyboard('{Escape}');
await waitFor(() => {
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
});
});
});

View File

@@ -21,6 +21,7 @@ type TestEntity = {
name: string;
namespace: string;
cluster: string;
meta?: Record<string, string>;
};
const mockEntity: TestEntity = {
@@ -29,14 +30,22 @@ const mockEntity: TestEntity = {
cluster: 'test-cluster',
};
function createBaseProps() {
/** The overview tab appears only for a resource whose meta relates it to others */
const relatedEntity: TestEntity = {
...mockEntity,
meta: {
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
'k8s.namespace.name': 'default',
'k8s.cluster.name': 'test-cluster',
},
};
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
return {
category: InfraMonitoringEntity.PODS,
category,
eventCategory: InfraMonitoringEvents.Pod,
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
fetchEntityData: jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null }),
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
getEntityName: (e: TestEntity): string => e.name,
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
@@ -51,7 +60,10 @@ function createBaseProps() {
interface RenderOptions {
view?: string;
category?: InfraMonitoringEntity;
entity?: TestEntity;
tabsConfig?: {
showOverview?: boolean;
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
@@ -67,6 +79,9 @@ interface RenderOptions {
function renderK8sBaseDetails({
view = VIEW_TYPES.METRICS,
// Volumes have no related resources, so they get no overview tab
category = InfraMonitoringEntity.VOLUMES,
entity = mockEntity,
tabsConfig,
customTabs,
}: RenderOptions = {}) {
@@ -78,7 +93,7 @@ function renderK8sBaseDetails({
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<K8sBaseDetails<TestEntity>
{...createBaseProps()}
{...createBaseProps(category, entity)}
tabsConfig={tabsConfig}
customTabs={customTabs}
/>
@@ -106,6 +121,25 @@ describe('K8sBaseDetails - Tab Validation', () => {
});
});
it('should land on the overview tab for a category with related resources', async () => {
act(() => {
renderK8sBaseDetails({
view: 'invalid-tab',
category: InfraMonitoringEntity.PODS,
entity: relatedEntity,
tabsConfig: { showOverview: true },
});
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Overview');
});
});
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
act(() => {
renderK8sBaseDetails({

View File

@@ -0,0 +1,154 @@
import { InfraMonitoringEntity } from '../../constants';
import {
buildRelatedFilterExpression,
carryOverListFilters,
getRelatedCategories,
} from '../relations';
/** What /api/v2/infra_monitoring/pods returns as a pod's meta. */
const POD_ATTRIBUTES = {
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
'k8s.pod.name': 'checkout-7d9f',
'k8s.namespace.name': 'shop',
'k8s.cluster.name': 'prod',
'k8s.node.name': 'node-1',
'k8s.deployment.name': 'checkout',
};
const NODE_ATTRIBUTES = {
'k8s.node.name': 'node-1',
'k8s.cluster.name': 'prod',
};
describe('getRelatedCategories', () => {
it('offers every other kubernetes category, never itself', () => {
expect(
getRelatedCategories(InfraMonitoringEntity.PODS, POD_ATTRIBUTES),
).toStrictEqual([
InfraMonitoringEntity.CONTAINERS,
InfraMonitoringEntity.DEPLOYMENTS,
InfraMonitoringEntity.STATEFULSETS,
InfraMonitoringEntity.DAEMONSETS,
InfraMonitoringEntity.JOBS,
InfraMonitoringEntity.VOLUMES,
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.NAMESPACES,
InfraMonitoringEntity.CLUSTERS,
]);
});
it('scopes a category it has no relation to by what they share', () => {
// Not this pod's statefulsets, which it has none of: the ones beside it
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.STATEFULSETS,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop'");
});
it('falls back to the cluster where that is all two categories share', () => {
// Deployment metrics carry no node, so this relation is unexpressible
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.DEPLOYMENTS,
NODE_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod'");
});
it('finds nothing for a host, which is not a kubernetes resource', () => {
// Host rows carry os.type and the host name, no kubernetes attribute
expect(
getRelatedCategories(InfraMonitoringEntity.HOSTS, {
'host.name': 'ip-10-0-0-1',
'os.type': 'linux',
}),
).toStrictEqual([]);
});
it('lists pods first, which makes them the default for a node', () => {
expect(
getRelatedCategories(InfraMonitoringEntity.NODES, NODE_ATTRIBUTES)[0],
).toBe(InfraMonitoringEntity.PODS);
});
});
describe('buildRelatedFilterExpression', () => {
it('scopes the pods of a node by the node, inside its cluster', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.PODS,
NODE_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
});
it("scopes a pod's containers by the pod uid, which those rows carry", () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CONTAINERS,
POD_ATTRIBUTES,
),
).toBe(
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.pod.uid = '63edbad0-398e-48e5-baba-f7ded7c73f1b'",
);
});
it("reaches a pod's deployment by name, the only key that endpoint has", () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.DEPLOYMENTS,
POD_ATTRIBUTES,
),
).toBe(
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.deployment.name = 'checkout'",
);
});
it('names the parent when the drawer resource belongs to it', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.NODES,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
});
it('asks a cluster list for the cluster alone', () => {
expect(
buildRelatedFilterExpression(
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CLUSTERS,
POD_ATTRIBUTES,
),
).toBe("k8s.cluster.name = 'prod'");
});
});
describe('carryOverListFilters', () => {
it('keeps the clauses the target endpoint understands', () => {
expect(
carryOverListFilters(
"k8s.namespace.name = 'shop' AND k8s.node.condition_ready = true",
InfraMonitoringEntity.PODS,
),
).toBe("k8s.namespace.name = 'shop'");
});
it('drops a filter written against the source entity only', () => {
expect(
carryOverListFilters('k8s.node.cpu.usage > 0.5', InfraMonitoringEntity.PODS),
).toBe('');
});
it('returns nothing for an empty list filter', () => {
expect(carryOverListFilters(' ', InfraMonitoringEntity.PODS)).toBe('');
});
});

View File

@@ -0,0 +1,48 @@
import type { ComponentProps } from 'react';
import {
ArrowUpDown,
Bolt,
Box,
Boxes,
Computer,
Container,
FilePenLine,
Group,
HardDrive,
Server,
Workflow,
} from '@signozhq/icons';
import { InfraMonitoringEntity } from '../constants';
/**
* One icon per category, so the section tabs and the drawer that opens from
* them show a resource the same way.
*/
type IconSize = ComponentProps<typeof Container>['size'];
const CATEGORY_ICONS: Record<InfraMonitoringEntity, typeof Container> = {
[InfraMonitoringEntity.HOSTS]: Server,
[InfraMonitoringEntity.PODS]: Container,
[InfraMonitoringEntity.CONTAINERS]: Box,
[InfraMonitoringEntity.NODES]: Workflow,
[InfraMonitoringEntity.NAMESPACES]: FilePenLine,
[InfraMonitoringEntity.CLUSTERS]: Boxes,
[InfraMonitoringEntity.DEPLOYMENTS]: Computer,
[InfraMonitoringEntity.STATEFULSETS]: ArrowUpDown,
[InfraMonitoringEntity.DAEMONSETS]: Group,
[InfraMonitoringEntity.JOBS]: Bolt,
[InfraMonitoringEntity.VOLUMES]: HardDrive,
};
export function CategoryIcon({
category,
size = 14,
}: {
category: InfraMonitoringEntity;
size?: IconSize;
}): JSX.Element {
const Icon = CATEGORY_ICONS[category];
return <Icon size={size} />;
}

View File

@@ -3,18 +3,11 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Compass } from '@signozhq/icons';
import { TextNoData } from '../../../components/TextNoData';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Link } from 'react-router-dom';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../../constants';
import { InfraMonitoringEntity } from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import { buildK8sListNavigationUrl } from '../../utils';
import styles from './EntityCountsSection.module.scss';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
@@ -55,70 +48,6 @@ export function EntityCountsSection<T>({
closeDrawer();
};
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
const compositeQuery = {
...defaultQuery,
id: uuid(),
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: filterExpression },
filters: { items: [], op: 'AND' as const },
})),
},
};
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
};
return (
<div className={styles.countsContainer}>
{countsConfig.map((config) => (
@@ -147,7 +76,7 @@ export function EntityCountsSection<T>({
<TextNoData type="typography" className={styles.countValue} />
)}
<Link
to={buildNavigationUrl(config.targetCategory)}
to={buildK8sListNavigationUrl(config.targetCategory, filterExpression)}
onClick={(): void => handleCardNavigate(config.label)}
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
>

View File

@@ -97,7 +97,11 @@ export function logInfraExplorerNavigatedEvent(params: {
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
source:
| 'chart_compass_icon'
| 'tab_cta_button'
| 'stats_card'
| 'overview_cta';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;

View File

@@ -0,0 +1,265 @@
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
/** The attribute that names one record of a category. */
const PRIMARY_KEY: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.CLUSTERS]: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
[InfraMonitoringEntity.NAMESPACES]:
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
[InfraMonitoringEntity.NODES]: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
[InfraMonitoringEntity.CONTAINERS]:
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
[InfraMonitoringEntity.DEPLOYMENTS]:
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
[InfraMonitoringEntity.STATEFULSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
[InfraMonitoringEntity.DAEMONSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
[InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
[InfraMonitoringEntity.VOLUMES]:
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
[InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
};
/**
* Attributes each list endpoint can be filtered by, broadest first so a built
* expression reads cluster to container. Only keys the category's metrics
* actually carry belong here.
*/
const FILTERABLE_KEYS: Record<InfraMonitoringEntity, string[]> = {
[InfraMonitoringEntity.CLUSTERS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
],
[InfraMonitoringEntity.NODES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
],
[InfraMonitoringEntity.NAMESPACES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
],
[InfraMonitoringEntity.DEPLOYMENTS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
],
[InfraMonitoringEntity.STATEFULSETS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
],
[InfraMonitoringEntity.DAEMONSETS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
],
[InfraMonitoringEntity.JOBS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
],
[InfraMonitoringEntity.VOLUMES]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
],
[InfraMonitoringEntity.PODS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
],
[InfraMonitoringEntity.CONTAINERS]: [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
],
[InfraMonitoringEntity.HOSTS]: [],
};
/** Dropdown order: what a resource usually contains, then what contains it. */
const DISPLAY_ORDER: InfraMonitoringEntity[] = [
InfraMonitoringEntity.PODS,
InfraMonitoringEntity.CONTAINERS,
InfraMonitoringEntity.DEPLOYMENTS,
InfraMonitoringEntity.STATEFULSETS,
InfraMonitoringEntity.DAEMONSETS,
InfraMonitoringEntity.JOBS,
InfraMonitoringEntity.VOLUMES,
InfraMonitoringEntity.NODES,
InfraMonitoringEntity.NAMESPACES,
InfraMonitoringEntity.CLUSTERS,
];
export type EntityAttributes = Record<string, string>;
/** The attribute a category's display name belongs under. */
const NAME_KEY: Partial<Record<InfraMonitoringEntity, string>> = {
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
};
export function getEntityNameAttributeKey(
category: InfraMonitoringEntity,
): string {
return NAME_KEY[category] ?? PRIMARY_KEY[category];
}
function matchingKeys(
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string[] {
return FILTERABLE_KEYS[target].filter((key) => !!attributes[key]);
}
/**
* The attribute that ties a target to the drawer's resource: the resource
* itself when the target's records belong to it (the containers of this pod),
* otherwise the target's own name as the resource knows it (the node this pod
* runs on). Null when neither holds, which leaves the target scoped only by
* the ancestors the two share.
*/
function relationKey(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string | null {
if (target === source) {
return null;
}
const keys = matchingKeys(target, attributes);
if (keys.includes(PRIMARY_KEY[source])) {
return PRIMARY_KEY[source];
}
return keys.includes(PRIMARY_KEY[target]) ? PRIMARY_KEY[target] : null;
}
/**
* Every category the drawer's resource can scope a list of, which for a
* kubernetes resource is all of them: where no closer relation exists the two
* still share a cluster. A target is left out only when nothing is shared at
* all, so a list is never asked for unscoped.
*/
export function getRelatedCategories(
source: InfraMonitoringEntity,
attributes: EntityAttributes,
): InfraMonitoringEntity[] {
return DISPLAY_ORDER.filter(
(target) =>
target !== source &&
buildRelatedFilterClauses(source, target, attributes).length > 0,
);
}
/**
* Cluster and namespace ride along with the relation itself, the way other
* observability tools scope these lists: a pod's deployment is read as that
* deployment, in that namespace, in that cluster.
*/
const SCOPE_KEYS = [
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
];
export interface RelatedFilterClause {
key: string;
value: string;
}
/**
* Scope clauses, broadest first. The last carries the relation where one
* exists; without one the list is scoped to the shared ancestors, which reads
* as "beside this resource" rather than "belonging to it".
*/
export function buildRelatedFilterClauses(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): RelatedFilterClause[] {
const key = relationKey(source, target, attributes);
const scope = SCOPE_KEYS.filter(
(scopeKey) =>
scopeKey !== key &&
FILTERABLE_KEYS[target].includes(scopeKey) &&
!!attributes[scopeKey],
);
return (key ? [...scope, key] : scope).map((clauseKey) => ({
key: clauseKey,
value: attributes[clauseKey] as string,
}));
}
export function formatRelatedFilterClause(clause: RelatedFilterClause): string {
return `${clause.key} = ${formatValueForExpression(clause.value)}`;
}
export function buildRelatedFilterExpression(
source: InfraMonitoringEntity,
target: InfraMonitoringEntity,
attributes: EntityAttributes,
): string {
return buildRelatedFilterClauses(source, target, attributes)
.map(formatRelatedFilterClause)
.join(' AND ');
}
export function resolveRelatedCategory(
source: InfraMonitoringEntity,
attributes: EntityAttributes,
requested: string | null,
): InfraMonitoringEntity | null {
const related = getRelatedCategories(source, attributes);
const match = related.find((target) => target === requested);
return match ?? related[0] ?? null;
}
export function carryOverListFilters(
expression: string,
target: InfraMonitoringEntity,
): string {
if (!expression.trim()) {
return '';
}
const supported = new Set(FILTERABLE_KEYS[target]);
return extractQueryPairs(expression)
.filter((pair) => supported.has(pair.key))
.map((pair) => {
const start = pair.position.negationStart ?? pair.position.keyStart;
const lastValue = pair.valuesPosition?.[pair.valuesPosition.length - 1];
const end =
lastValue?.end ?? pair.position.valueEnd ?? pair.position.operatorEnd;
return expression.slice(start, end + 1).trim();
})
.filter(Boolean)
.join(' AND ');
}

View File

@@ -75,6 +75,8 @@ export type GetEntityQueryPayload<T> = (
) => GetQueryResultsProps[];
export interface K8sDetailsTabsConfig {
/** Declared by categories whose resources relate to others; see relations.ts */
showOverview?: boolean;
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
@@ -141,6 +143,10 @@ export interface K8sBaseDetailsContentProps<T> {
customTabs?: K8sDetailsCustomTab<T>[];
logsAndTracesInitialExpression: string;
eventsInitialExpression: string;
/** The drawer entity's own attributes, used to find and scope related resources */
entityAttributes: Record<string, string>;
/** Name of the drawer entity, for the back control of a resource opened from it */
entityName: string;
}
// Aliases for backward compatibility

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
export interface DrawerHistoryEntry {
params: SelectedItemParams;
/** Category of the resource that was open, which the back control names */
category: InfraMonitoringEntity;
/** Name of that resource, shown on hover */
label: string;
/**
* The record the drawer was showing, and the prefix of the query that holds
* it, so stepping back renders from it instead of fetching again.
*/
entity: unknown;
queryKeyPrefix: string;
}
export interface IDrawerHistoryStore {
entries: DrawerHistoryEntry[];
push: (entry: DrawerHistoryEntry) => void;
pop: () => DrawerHistoryEntry | null;
reset: () => void;
}
/**
* The trail left by opening one resource from another's overview tab, so the
* drawer can step back through it instead of only closing.
*/
export const useDrawerHistoryStore = create<IDrawerHistoryStore>()(
(set, get) => ({
entries: [],
push: (entry): void => set({ entries: [...get().entries, entry] }),
pop: (): DrawerHistoryEntry | null => {
const { entries } = get();
const previous = entries[entries.length - 1] ?? null;
if (previous) {
set({ entries: entries.slice(0, -1) });
}
return previous;
},
reset: (): void => set({ entries: [] }),
}),
);
export const resetDrawerHistory = (): void =>
useDrawerHistoryStore.getState().reset();
export const pushDrawerHistory = (entry: DrawerHistoryEntry): void =>
useDrawerHistoryStore.getState().push(entry);
export const getDrawerHistoryDepth = (): number =>
useDrawerHistoryStore.getState().entries.length;

View File

@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useGlobalTimeStore } from 'store/globalTime';
import { SelectedItemParams } from '../hooks';
interface PrimeEntityDetailsParams {
params: SelectedItemParams;
queryKeyPrefix: string;
entity: unknown;
}
/**
* Seeds the drawer's entity query with a record already in hand, so navigating
* between resources renders straight away instead of passing through a loading
* state. The list page does the same on a row click.
*/
export function usePrimeEntityDetails(): (
params: PrimeEntityDetailsParams,
) => void {
const queryClient = useQueryClient();
const selectedTime = useGlobalTimeStore((store) => store.selectedTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(store) => store.getAutoRefreshQueryKey,
);
return useCallback(
({ params, queryKeyPrefix, entity }): void => {
if (!queryKeyPrefix || !entity) {
return;
}
queryClient.setQueryData(
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
),
{ data: entity },
);
},
[queryClient, selectedTime, getAutoRefreshQueryKey],
);
}

View File

@@ -7,7 +7,16 @@ import {
formatValueForExpression,
} from 'components/QueryBuilderV2/utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import {
INFRA_MONITORING_ATTR_KEYS,
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export function sortByColumnOrder<T>(
items: T[],
@@ -178,3 +187,74 @@ export function buildExpressionFromSelectedItemParams(
return clauses.join(' AND ');
}
/**
* Link to the k8s list page for `targetCategory`, pre-filtered by `filterExpression`
* and carrying whichever time range (list or drawer) the current URL holds.
*/
export function buildK8sListNavigationUrl(
targetCategory: InfraMonitoringEntity,
filterExpression: string,
): string {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
const compositeQuery = {
...defaultQuery,
id: uuid(),
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: filterExpression },
filters: { items: [], op: 'AND' as const },
})),
},
};
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
}

View File

@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
K8sDetailsCountConfig,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -123,6 +123,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
details: {
category: InfraMonitoringEntity.CLUSTERS,
eventCategory: InfraMonitoringEvents.Cluster,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'cluster',
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
fetchEntityData,

View File

@@ -127,6 +127,7 @@ export const containerEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
fetchEntityData,

View File

@@ -1,4 +1,4 @@
import { Container } from '@signozhq/icons';
import { Box } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerReadyDTO,
@@ -94,7 +94,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
header: (): React.ReactNode => (
<EntityGroupHeader
title="Container Name"
icon={<Container data-hide-expanded="true" size={14} />}
icon={<Box data-hide-expanded="true" size={14} />}
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
/>
),

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const daemonSetEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.DAEMONSETS,
eventCategory: InfraMonitoringEvents.DaemonSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'daemonset',
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -147,6 +147,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.DEPLOYMENTS,
eventCategory: InfraMonitoringEvents.Deployment,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'deployment',
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
fetchEntityData,

View File

@@ -0,0 +1,166 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
min-width: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.tableHeader {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l1-border);
}
.resourcePicker {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 0 0 auto;
}
.resourceTrigger {
--select-trigger-width: 240px;
--select-trigger-height: 2rem;
}
.resourceOptions {
// The drawer is a dialog at z-index 50 and select content ships without one,
// so without this the dropdown paints behind the drawer.
z-index: 60;
// Radix measures the trigger for us; without this the list is narrower than it
min-width: var(--radix-select-trigger-width);
}
.filterRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
min-width: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
// The row draws the field outline, so the editor inside it draws none
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
}
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filterInput {
flex: 1 1 auto;
min-width: 0;
// The locked scope is already shown as a chip, so drop the editor's own
// scope marker and the room it reserves for it
:global(.query-search-initial-scope-label) {
display: none;
}
:global(
.query-where-clause-editor.hasInitialExpression .cm-editor .cm-content
) {
padding-left: 0 !important;
}
// Its page-width minimum would force the filter row to overflow the drawer
:global(.code-mirror-where-clause .query-where-clause-editor) {
min-width: 0;
}
}
.tableCard {
display: flex;
flex-direction: column;
min-height: 480px;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
min-width: 0;
}
.table {
flex: 1 1 auto;
min-height: 0;
--typography-color: var(--l2-foreground);
}
.noRelations {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
margin-top: var(--spacing-8);
}
.emptyState {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}

View File

@@ -0,0 +1,76 @@
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
function buildContainers(count: number): unknown[] {
return Array.from({ length: count }, (_, index) => ({
containerName: index === 0 ? 'loadgenerator' : `sidecar-${index}`,
containerID: `containerd://${index}`,
podName: 'app-demo-loadgenerator-579fbdd749-q5kgd',
status: 'running',
ready: true,
restarts: index,
image: 'ghcr.io/open-telemetry/demo:1.14.0-loadgenerator',
cpuUsage: 0.12,
memoryUsage: 148 * 1024 * 1024,
meta: {
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
},
}));
}
export const overviewMocks = defineStoryMocks({
controls: {
containers: countControl('Containers', {
group: 'Overview tab',
value: 12,
max: 40,
}),
},
handlers: (values) => [
// The filter's suggestions ask for these; without them the search box
// logs a failed request and the story's smoke test fails on it
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/kube_containers',
async (req, res, ctx) => {
const { offset = 0, limit = 10 } = (await req.json()) as {
offset?: number;
limit?: number;
};
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
type: 'list',
records: buildContainers(values.containers).slice(
offset,
offset + limit,
),
total: values.containers,
},
}),
);
},
),
],
});

View File

@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { GlobalTimeProvider } from 'store/globalTime';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
import EntityOverview, { EntityOverviewProps } from './EntityOverview';
import { overviewMocks } from './EntityOverview.stories.mocks';
import drawerStyles from '../entityDetails.module.scss';
const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
/** The meta /api/v2/infra_monitoring/pods returns for one pod. */
const POD_ATTRIBUTES = {
'k8s.pod.uid': POD_UID,
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
'k8s.namespace.name': 'generator',
'k8s.cluster.name': 'mgmt',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
'k8s.deployment.name': 'app-demo-loadgenerator',
};
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
type OverviewArgs = PageStoryArgs<typeof overviewMocks> & EntityOverviewProps;
const meta = {
title: 'Infra monitoring/Drawer overview tab',
component: EntityOverview,
...storyMocks(overviewMocks, { route }),
decorators: [
(Story): JSX.Element => (
<DrawerWrapper
open
direction="right"
title="app-demo-loadgenerator-579fbdd749-q5kgd"
onOpenChange={(): void => undefined}
className={drawerStyles.entityDetailDrawer}
>
<GlobalTimeProvider>
<Story />
</GlobalTimeProvider>
</DrawerWrapper>
),
],
} satisfies Meta<OverviewArgs>;
export default meta;
type Story = StoryObj<OverviewArgs>;
export const Default: Story = {
args: {
category: InfraMonitoringEntity.PODS,
eventEntity: 'pod',
attributes: POD_ATTRIBUTES,
entity: { podUID: POD_UID },
entityName: POD_ATTRIBUTES['k8s.pod.name'],
queryKeyPrefix: 'pod',
},
};

View File

@@ -0,0 +1,343 @@
import { useCallback, useMemo, useState } from 'react';
import { Compass } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import {
QuerySearchV2Provider,
useExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { validateQuery } from 'utils/queryValidationUtils';
import { getEntityConfig } from '../../Base/entity.registry';
import { logInfraExplorerNavigatedEvent } from '../../Base/events';
import {
buildRelatedFilterClauses,
EntityAttributes,
formatRelatedFilterClause,
RelatedFilterClause,
getEntityNameAttributeKey,
getRelatedCategories,
resolveRelatedCategory,
} from '../../Base/relations';
import { getDrawerDurationMs } from '../../Base/useDrawerLifecycleStore';
import { buildK8sListNavigationUrl } from '../../Base/utils';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
K8S_CATEGORY_LABELS,
K8S_CATEGORY_SINGULAR_LABELS,
METRIC_NAMESPACE_BY_ENTITY,
VIEW_TYPES,
} from '../../constants';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import { logInfraDrawerFilterCustomizedEvent } from '../events';
import { RelatedEntitiesTable } from './RelatedEntitiesTable';
import { ScopeChip } from './ScopeChip';
import { useCarriedOverFilters } from './useCarriedOverFilters';
import { useRelatedEntities } from './useRelatedEntities';
import styles from './EntityOverview.module.scss';
export interface EntityOverviewProps {
category: InfraMonitoringEntity;
eventEntity: string;
/** The drawer entity's own attributes, which scope every related query */
attributes: EntityAttributes;
/** The drawer's own record and query prefix, so a row click can leave a
* trail that steps back without fetching again */
entity: unknown;
entityName: string;
queryKeyPrefix: string;
}
interface ContentProps extends EntityOverviewProps {
targetCategory: InfraMonitoringEntity;
lockedExpression: string;
lockedClauses: RelatedFilterClause[];
onCategoryChange: (category: string) => void;
}
function EntityOverviewContent({
category,
eventEntity,
attributes,
entity,
entityName,
queryKeyPrefix,
targetCategory,
lockedExpression,
lockedClauses,
onCategoryChange,
}: ContentProps): JSX.Element | null {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const userExpression = useUserExpression();
const inputExpression = useInputExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const targetConfig = getEntityConfig(targetCategory);
useCarriedOverFilters(targetCategory);
const {
records,
total,
isLoading,
isFetching,
isError,
error,
endTimeBeforeRetention,
setPage,
setLimit,
refetch,
cancel,
} = useRelatedEntities({
fetchListData: targetConfig?.list.fetchListData,
queryKey: `${targetCategory}RelatedEntities`,
expression,
timeRange,
});
// The editor holds only what the user typed; the locked scope is shown beside it
const queryData = useMemo(
(): IBuilderQuery =>
({
aggregateOperator: 'noop',
filter: { expression: userExpression },
}) as IBuilderQuery,
[userExpression],
);
const handleCategoryChange = useCallback(
(value: string | string[]): void => {
if (typeof value === 'string') {
onCategoryChange(value);
}
},
[onCategoryChange],
);
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(lockedExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
combineInitialAndUserExpression(lockedExpression, newUserExpression || ''),
);
if (!validation.isValid) {
return;
}
querySearchOnRun(newUserExpression);
logInfraDrawerFilterCustomizedEvent(
category,
VIEW_TYPES.OVERVIEW,
newUserExpression || '',
'search',
);
refetch();
},
[lockedExpression, inputExpression, querySearchOnRun, category, refetch],
);
const handleNavigate = useCallback((): void => {
logInfraExplorerNavigatedEvent({
entityType: category,
destination: 'k8s_list',
source: 'overview_cta',
tab: VIEW_TYPES.OVERVIEW,
sourceKey: targetCategory,
drawerDurationMsAtNavigation: getDrawerDurationMs(),
});
openInNewTab(buildK8sListNavigationUrl(targetCategory, expression));
}, [category, targetCategory, expression]);
if (!targetConfig) {
return null;
}
const targetLabel = K8S_CATEGORY_LABELS[targetCategory];
return (
<div className={styles.container}>
<div className={styles.controls}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={VIEW_TYPES.OVERVIEW}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterRow}>
<ScopeChip clauses={lockedClauses} expression={lockedExpression} />
<div className={styles.filterInput}>
<QuerySearch
queryData={queryData}
dataSource={DataSource.METRICS}
onChange={querySearchOnChange}
onRun={handleRunQuery}
signalSource=""
showFilterSuggestionsWithoutMetric
placeholder={`Filter these ${targetLabel.toLowerCase()}`}
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[targetCategory]}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
<RelatedEntitiesTable
key={targetCategory}
targetCategory={targetCategory}
header={
<div className={styles.resourcePicker}>
<Typography.Text color="muted" size="small">
Showing
</Typography.Text>
<Select value={targetCategory} onChange={handleCategoryChange}>
<SelectTrigger
className={styles.resourceTrigger}
data-testid="overview-resource-select"
>
<SelectValue>{targetLabel}</SelectValue>
</SelectTrigger>
{/* The drawer sits at z-index 50 and select content ships without one */}
<SelectContent className={styles.resourceOptions}>
{getRelatedCategories(category, attributes).map((relatedCategory) => (
<SelectItem
key={relatedCategory}
value={relatedCategory}
testId={`overview-resource-option-${relatedCategory}`}
>
{K8S_CATEGORY_LABELS[relatedCategory]}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipSimple
title={`View on ${targetLabel.toLowerCase()} page`}
side="top"
arrow
>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNavigate}
data-testid="overview-view-on-list-page"
prefix={<Compass size={16} />}
/>
</TooltipSimple>
</div>
}
listConfig={targetConfig.list}
records={records}
total={total}
isLoading={isLoading}
isError={isError}
error={error}
endTimeBeforeRetention={endTimeBeforeRetention}
source={{ category, label: entityName, entity, queryKeyPrefix }}
onPageChange={setPage}
onLimitChange={setLimit}
/>
</div>
);
}
export default function EntityOverview(
props: EntityOverviewProps,
): JSX.Element | null {
const { category, attributes } = props;
// Tied to the resource it was picked on, so another drawer starts at its
// own default instead of inheriting the last pick
const [selection, setSelection] = useState<{
identity: string;
category: string;
} | null>(null);
const identity = `${category}:${
attributes[getEntityNameAttributeKey(category)] ?? ''
}`;
const targetCategory = resolveRelatedCategory(
category,
attributes,
selection?.identity === identity ? selection.category : null,
);
const handleCategoryChange = useCallback(
(value: string): void => setSelection({ identity, category: value }),
[identity],
);
const lockedClauses = targetCategory
? buildRelatedFilterClauses(category, targetCategory, attributes)
: [];
const lockedExpression = lockedClauses
.map(formatRelatedFilterClause)
.join(' AND ');
if (!targetCategory) {
return (
<div className={styles.noRelations}>
<Typography.Text color="muted">
{`Nothing related to this ${K8S_CATEGORY_SINGULAR_LABELS[category]} was reported in this time range.`}
</Typography.Text>
</div>
);
}
return (
<QuerySearchV2Provider
queryParamKey={INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION}
initialExpression={lockedExpression}
>
<EntityOverviewContent
{...props}
targetCategory={targetCategory}
lockedExpression={lockedExpression}
lockedClauses={lockedClauses}
onCategoryChange={handleCategoryChange}
/>
</QuerySearchV2Provider>
);
}

View File

@@ -0,0 +1,142 @@
import { ReactNode, useCallback } from 'react';
import cx from 'classnames';
import TanStackTable from 'components/TanStackTableView';
import APIError from 'types/api/error';
import {
K8sEntityData,
K8sEntityListConfig,
} from '../../Base/entity.config.types';
import { K8sEmptyState } from '../../Base/K8sEmptyState';
import {
DrawerHistoryEntry,
useDrawerHistoryStore,
} from '../../Base/useDrawerHistoryStore';
import {
useInfraMonitoringFontSize,
useInfraMonitoringLineClamp,
} from '../../Base/useInfraMonitoringTablePreferencesStore';
import { InfraMonitoringEntity, K8S_CATEGORY_LABELS } from '../../constants';
import {
SelectedItemParams,
useInfraMonitoringSelectedItemParams,
} from '../../hooks';
import { usePrimeEntityDetails } from '../../Base/usePrimeEntityDetails';
import { RELATED_ENTITIES_PAGE_SIZE } from './useRelatedEntities';
import listStyles from '../../Base/K8sBaseList.module.scss';
import styles from './EntityOverview.module.scss';
interface RelatedEntitiesTableProps {
targetCategory: InfraMonitoringEntity;
listConfig: K8sEntityListConfig<K8sEntityData, string | SelectedItemParams>;
records: K8sEntityData[];
total: number;
isLoading: boolean;
isError: boolean;
error?: APIError | null;
endTimeBeforeRetention?: boolean;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
/** Rendered in the card's header strip, above the rows it describes */
header: ReactNode;
/** The drawer's own resource, so a row click can leave a trail back to it */
source: Omit<DrawerHistoryEntry, 'params'>;
}
export function RelatedEntitiesTable({
targetCategory,
listConfig,
records,
total,
isLoading,
isError,
error,
endTimeBeforeRetention,
onPageChange,
onLimitChange,
header,
source,
}: RelatedEntitiesTableProps): JSX.Element {
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const pushDrawerHistory = useDrawerHistoryStore((store) => store.push);
const primeDetails = usePrimeEntityDetails();
const lineClamp = useInfraMonitoringLineClamp();
const fontSize = useInfraMonitoringFontSize();
// Opens the clicked resource in its own drawer, overview tab included
const handleRowClick = useCallback(
(record: K8sEntityData, itemKey: string | SelectedItemParams): void => {
const params: SelectedItemParams =
typeof itemKey === 'object'
? itemKey
: { selectedItem: itemKey, clusterName: null, namespaceName: null };
pushDrawerHistory({ ...source, params: selectedItemParams });
// The row already carries the record the drawer is about to fetch
primeDetails({
params,
queryKeyPrefix: listConfig.detailsQueryKeyPrefix,
entity: record,
});
setSelectedItemParams({ ...params, category: targetCategory });
},
[
primeDetails,
pushDrawerHistory,
selectedItemParams,
source,
setSelectedItemParams,
targetCategory,
listConfig.detailsQueryKeyPrefix,
],
);
const isEmpty = !isLoading && records.length === 0;
return (
<div className={styles.tableCard}>
<div className={styles.tableHeader}>{header}</div>
{isEmpty ? (
<div className={styles.emptyState}>
<K8sEmptyState
isError={isError}
error={error}
isLoading={isLoading}
endTimeBeforeRetention={endTimeBeforeRetention}
/>
</div>
) : (
<TanStackTable<K8sEntityData, string | SelectedItemParams>
data={records}
columns={listConfig.tableColumns}
columnStorageKey={`k8s-overview-${targetCategory}-columns`}
isLoading={isLoading}
getRowKey={listConfig.getRowKey}
getItemKey={listConfig.getItemKey}
onRowClick={handleRowClick}
className={cx(listStyles.k8SListTable, styles.table)}
testId={`related-entities-table-${targetCategory}`}
// A page of rows is short enough to render whole, which keeps the card
// at the height of its rows instead of stretching down the drawer
disableVirtualScroll
pagination={{
total,
defaultLimit: RELATED_ENTITIES_PAGE_SIZE,
showTotalCount: true,
totalCountLabel: K8S_CATEGORY_LABELS[targetCategory],
onPageChange,
onLimitChange,
}}
plainTextCellLineClamp={lineClamp}
cellTypographySize={fontSize}
paginationClassname={listStyles.paginationContainer}
resetScrollKey={targetCategory}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Lock } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { RelatedFilterClause } from '../../Base/relations';
import styles from './EntityOverview.module.scss';
interface ScopeChipProps {
clauses: RelatedFilterClause[];
/** The clauses as one expression, shown in full on hover */
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({
clauses,
expression,
}: ScopeChipProps): JSX.Element {
const relation = clauses[clauses.length - 1];
return (
<TooltipSimple title={expression} side="top" arrow>
<span className={styles.scopeChip} data-testid="overview-locked-scope">
<Lock size={12} />
<span className={styles.scopeExpression}>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}> = </span>
<span className={styles.scopeValue}>
{formatValueForExpression(relation.value)}
</span>
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</TooltipSimple>
);
}

View File

@@ -0,0 +1,242 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../../constants';
import EntityOverview from '../EntityOverview';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
const mockQuerySearchProps = jest.fn();
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: (props: {
queryData?: { filter?: { expression?: string } };
initialExpression?: string;
}): JSX.Element => {
mockQuerySearchProps(props);
return <div data-testid="mock-query-search" />;
},
}));
type TestRecord = { name: string; meta?: Record<string, string> | null };
const mockFetchListData = jest.fn();
const mockTableColumns: TableColumnDef<TestRecord>[] = [
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
];
jest.mock('../../../Base/entity.registry', () => ({
getEntityConfig: (category: string) => ({
list: {
entity: category,
tableColumns: mockTableColumns,
fetchListData: mockFetchListData,
getRowKey: (record: TestRecord): string => record.name,
getItemKey: (record: TestRecord): string => record.name,
},
}),
}));
const NODE_ATTRIBUTES = {
'k8s.node.name': 'node-1',
'k8s.cluster.name': 'prod',
};
const LOCKED_EXPRESSION =
"k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'";
interface RenderOptions {
onUrlUpdate?: OnUrlUpdateFunction;
attributes?: Record<string, string>;
}
function overviewTree(
attributes: Record<string, string>,
queryClient: QueryClient,
onUrlUpdate?: OnUrlUpdateFunction,
): JSX.Element {
return (
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<EntityOverview
category={InfraMonitoringEntity.NODES}
eventEntity="node"
attributes={attributes}
entity={{ nodeName: attributes['k8s.node.name'] }}
entityName={attributes['k8s.node.name']}
queryKeyPrefix="node"
/>
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
</QueryClientProvider>
</MemoryRouter>
);
}
function renderOverview({
onUrlUpdate,
attributes = NODE_ATTRIBUTES,
}: RenderOptions = {}): {
openAnotherResource: (next: Record<string, string>) => void;
} {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { rerender } = render(
overviewTree(attributes, queryClient, onUrlUpdate),
);
return {
openAnotherResource: (next): void =>
rerender(overviewTree(next, queryClient, onUrlUpdate)),
};
}
describe('EntityOverview', () => {
beforeEach(() => {
mockQuerySearchProps.mockReset();
mockFetchListData.mockReset();
mockFetchListData.mockResolvedValue({
type: 'list',
records: [{ name: 'pod-1' }],
total: 1,
});
});
it('lists the first related resource scoped by the locked expression', async () => {
renderOverview();
await waitFor(() => {
expect(mockFetchListData).toHaveBeenCalled();
});
expect(mockFetchListData.mock.calls[0][0].filter.expression).toBe(
LOCKED_EXPRESSION,
);
await expect(
screen.findByTestId('related-entities-table-pods'),
).resolves.toBeInTheDocument();
});
it('keeps the locked scope out of the editable filter', async () => {
renderOverview();
// The chip leads with the relation and counts the scope behind it
const chip = await screen.findByTestId('overview-locked-scope');
expect(chip).toHaveTextContent("k8s.node.name = 'node-1'");
expect(chip).toHaveTextContent('+1');
// The editor is handed the user's own expression, never the locked scope
const lastProps = mockQuerySearchProps.mock.calls.at(-1)?.[0];
expect(lastProps.queryData.filter.expression).toBe('');
expect(lastProps.initialExpression).toBe(LOCKED_EXPRESSION);
});
it('opens the clicked resource in its own drawer', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn();
renderOverview({ onUrlUpdate });
const row = await screen.findByText('pod-1');
await user.click(row);
await waitFor(() => {
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
expect(
params.some(
(searchParams) =>
searchParams.get('selectedItem') === 'pod-1' &&
searchParams.get('selectedItemCategory') === 'pods',
),
).toBe(true);
});
});
it("forgets the previous drawer's resource when another one opens", async () => {
const user = userEvent.setup();
const { openAnotherResource } = renderOverview();
await user.click(screen.getByTestId('overview-resource-select'));
await user.click(
await screen.findByTestId('overview-resource-option-containers'),
);
await expect(
screen.findByTestId('related-entities-table-containers'),
).resolves.toBeInTheDocument();
openAnotherResource({ ...NODE_ATTRIBUTES, 'k8s.node.name': 'node-2' });
// Back to pods, this node's default, rather than the containers just picked
await expect(
screen.findByTestId('related-entities-table-pods'),
).resolves.toBeInTheDocument();
});
it('says so when the resource relates to nothing', async () => {
renderOverview({ attributes: { 'os.type': 'linux' } });
await expect(
screen.findByText(/Nothing related to this Node/),
).resolves.toBeInTheDocument();
expect(mockFetchListData).not.toHaveBeenCalled();
});
it('switches the listed resource from the dropdown', async () => {
const user = userEvent.setup();
renderOverview();
await waitFor(() => {
expect(mockFetchListData).toHaveBeenCalled();
});
await user.click(screen.getByTestId('overview-resource-select'));
// Every resource related to a node is offered, the listed one included
await expect(
screen.findByTestId('overview-resource-option-pods'),
).resolves.toBeInTheDocument();
await user.click(
await screen.findByTestId('overview-resource-option-containers'),
);
await expect(
screen.findByTestId('related-entities-table-containers'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react';
import { useQuerySearchOnRun } from 'components/QueryBuilderV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { parseAsString, useQueryState } from 'nuqs';
import { carryOverListFilters } from '../../Base/relations';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../../constants';
/**
* Seeds the editable half of the filter with whatever the list page was
* filtered by, reduced to the clauses the listed resource understands. Runs
* once per resource, so it never overwrites what the user types, and stands
* aside for a drawer opened from a link that already carries a filter.
*/
export function useCarriedOverFilters(
targetCategory: InfraMonitoringEntity,
): void {
const { currentQuery } = useQueryBuilder();
const listExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const querySearchOnRun = useQuerySearchOnRun();
const [urlExpression] = useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION,
parseAsString,
);
const seededFor = useRef<string | null>(null);
const hadUrlExpressionOnMount = useRef<boolean | null>(null);
if (hadUrlExpressionOnMount.current === null) {
hadUrlExpressionOnMount.current = !!urlExpression;
}
useEffect(() => {
if (seededFor.current === targetCategory) {
return;
}
const isFirstResource = seededFor.current === null;
seededFor.current = targetCategory;
if (isFirstResource && hadUrlExpressionOnMount.current) {
return;
}
querySearchOnRun(carryOverListFilters(listExpression, targetCategory));
}, [targetCategory, listExpression, querySearchOnRun]);
}

View File

@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import APIError from 'types/api/error';
import {
K8sEntityData,
K8sEntityListConfig,
} from '../../Base/entity.config.types';
import { SelectedItemParams } from '../../hooks';
type FetchRelatedEntities = K8sEntityListConfig<
K8sEntityData,
string | SelectedItemParams
>['fetchListData'];
interface UseRelatedEntitiesParams {
/** Absent while the target category is still unresolved; the query stays idle */
fetchListData?: FetchRelatedEntities;
queryKey: string;
expression: string;
/** Time range in seconds — see useEntityDetailsTime */
timeRange: { startTime: number; endTime: number };
}
interface UseRelatedEntitiesResult {
records: K8sEntityData[];
total: number;
page: number;
limit: number;
setPage: (page: number) => void;
setLimit: (limit: number) => void;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: APIError | null;
endTimeBeforeRetention?: boolean;
refetch: () => void;
cancel: () => void;
}
export const RELATED_ENTITIES_PAGE_SIZE = 10;
const SECOND_IN_MS = 1000;
export function useRelatedEntities({
fetchListData,
queryKey,
expression,
timeRange,
}: UseRelatedEntitiesParams): UseRelatedEntitiesResult {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(RELATED_ENTITIES_PAGE_SIZE);
useEffect(() => {
setPage(1);
}, [expression, queryKey]);
const reactQueryKey = useMemo(
() => [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
page,
limit,
],
[queryKey, timeRange.startTime, timeRange.endTime, expression, page, limit],
);
const { data, isLoading, isFetching, isError, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: ({ signal }) =>
(fetchListData as FetchRelatedEntities)(
{
filter: { expression },
offset: (page - 1) * limit,
limit,
// The list APIs take milliseconds, useEntityDetailsTime gives seconds
start: timeRange.startTime * SECOND_IN_MS,
end: timeRange.endTime * SECOND_IN_MS,
},
signal,
),
enabled: !!fetchListData && !!expression.trim(),
});
const cancel = useCallback((): void => {
void queryClient.cancelQueries({ queryKey: reactQueryKey });
}, [queryClient, reactQueryKey]);
const handleRefetch = useCallback((): void => {
void refetch();
}, [refetch]);
return {
records: data?.records ?? data?.data ?? [],
total: data?.total ?? 0,
page,
limit,
setPage,
setLimit,
isLoading,
isFetching,
isError,
error: data?.error,
endTimeBeforeRetention: data?.endTimeBeforeRetention,
refetch: handleRefetch,
cancel,
};
}

View File

@@ -2,7 +2,7 @@ import { Container } from '@signozhq/icons';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
import { CustomTab } from '../Base/types';
import {
InfraMonitoringEntity,
podUtilizationByPodWidgetInfo,

View File

@@ -26,7 +26,10 @@
overflow-y: auto;
padding: var(--spacing-8);
margin: 0;
min-width: 900px;
// On a 13" screen the drawer itself is narrower than 900px, and a flat
// minimum there clips the right edge of every tab
box-sizing: border-box;
min-width: min(900px, 100%);
}
}
@@ -34,6 +37,29 @@
--button-padding: var(--spacing-2);
}
.backButton {
--button-padding: var(--spacing-2) var(--spacing-3);
flex: none;
white-space: nowrap;
}
// The kind reads as a label on the name, not as part of it
.entityKind {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: none;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.title {
font-family: var(--periscope-font-family-mono);

View File

@@ -1,4 +1,7 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import type {
InfraMonitoringEntity,
VIEWS,
} from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
@@ -15,7 +18,7 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
tab: `${VIEWS}`,
expression: string,
filterSource: 'search' | 'logs',
): void {

View File

@@ -12,23 +12,11 @@ import {
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
Box,
Boxes,
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
} from '@signozhq/icons';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
@@ -42,6 +30,7 @@ import {
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
InfraMonitoringEntity,
K8S_CATEGORY_LABELS,
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
@@ -156,62 +145,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
() => [
{
key: K8sCategories.CONTAINERS,
label: 'Containers',
icon: <Box size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CONTAINERS],
icon: <CategoryIcon category={InfraMonitoringEntity.CONTAINERS} />,
config: GetContainersQuickFiltersConfig(),
},
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.PODS],
icon: <CategoryIcon category={InfraMonitoringEntity.PODS} />,
config: GetPodsQuickFiltersConfig(),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NODES],
icon: <CategoryIcon category={InfraMonitoringEntity.NODES} />,
config: GetNodesQuickFiltersConfig(),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NAMESPACES],
icon: <CategoryIcon category={InfraMonitoringEntity.NAMESPACES} />,
config: GetNamespaceQuickFiltersConfig(),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CLUSTERS],
icon: <CategoryIcon category={InfraMonitoringEntity.CLUSTERS} />,
config: GetClustersQuickFiltersConfig(),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DEPLOYMENTS],
icon: <CategoryIcon category={InfraMonitoringEntity.DEPLOYMENTS} />,
config: GetDeploymentsQuickFiltersConfig(),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.JOBS],
icon: <CategoryIcon category={InfraMonitoringEntity.JOBS} />,
config: GetJobsQuickFiltersConfig(),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DAEMONSETS],
icon: <CategoryIcon category={InfraMonitoringEntity.DAEMONSETS} />,
config: GetDaemonsetsQuickFiltersConfig(),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.STATEFULSETS],
icon: <CategoryIcon category={InfraMonitoringEntity.STATEFULSETS} />,
config: GetStatefulsetsQuickFiltersConfig(),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.VOLUMES],
icon: <CategoryIcon category={InfraMonitoringEntity.VOLUMES} />,
config: GetVolumesQuickFiltersConfig(),
},
],

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const jobEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.JOBS,
eventCategory: InfraMonitoringEvents.Job,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'job',
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
K8sDetailsCountConfig,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -143,6 +143,7 @@ export const namespaceEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.NAMESPACES,
eventCategory: InfraMonitoringEvents.Namespace,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'namespace',
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
fetchEntityData,

View File

@@ -12,7 +12,7 @@ import {
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';

View File

@@ -129,6 +129,7 @@ export const nodeEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.NODES,
eventCategory: InfraMonitoringEvents.Node,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'node',
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,

View File

@@ -121,6 +121,7 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
details: {
category: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'pod',
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,

View File

@@ -132,6 +132,7 @@ export const statefulSetEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.STATEFULSETS,
eventCategory: InfraMonitoringEvents.StatefulSet,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'statefulSet',
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
fetchEntityData,

View File

@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { K8sDetailsMetadataConfig } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {

View File

@@ -127,6 +127,7 @@ export const volumeEntityConfig: K8sEntityConfig<
details: {
category: InfraMonitoringEntity.VOLUMES,
eventCategory: InfraMonitoringEvents.Volume,
tabsConfig: { showOverview: true },
queryKeyPrefix: 'volume',
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
fetchEntityData,

View File

@@ -157,9 +157,11 @@ export enum VIEWS {
PROCESSES = 'processes',
EVENTS = 'events',
POD_METRICS = 'pod_metrics',
OVERVIEW = 'overview',
}
export const VIEW_TYPES = {
OVERVIEW: VIEWS.OVERVIEW,
METRICS: VIEWS.METRICS,
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
@@ -896,6 +898,41 @@ export const getInvalidValueTooltipText = (
attribute: string,
): string => `Some ${entity} do not have ${attribute}s.`;
export const K8S_CATEGORY_LABELS: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.HOSTS]: 'Hosts',
[InfraMonitoringEntity.PODS]: 'Pods',
[InfraMonitoringEntity.NODES]: 'Nodes',
[InfraMonitoringEntity.NAMESPACES]: 'Namespaces',
[InfraMonitoringEntity.CLUSTERS]: 'Clusters',
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployments',
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSets',
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSets',
[InfraMonitoringEntity.CONTAINERS]: 'Containers',
[InfraMonitoringEntity.JOBS]: 'Jobs',
[InfraMonitoringEntity.VOLUMES]: 'Volumes',
};
/**
* Used where one record is named, like the drawer's back control. Cased the
* same way as the plural labels, so StatefulSet keeps its capital.
*/
export const K8S_CATEGORY_SINGULAR_LABELS: Record<
InfraMonitoringEntity,
string
> = {
[InfraMonitoringEntity.HOSTS]: 'Host',
[InfraMonitoringEntity.PODS]: 'Pod',
[InfraMonitoringEntity.NODES]: 'Node',
[InfraMonitoringEntity.NAMESPACES]: 'Namespace',
[InfraMonitoringEntity.CLUSTERS]: 'Cluster',
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployment',
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSet',
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSet',
[InfraMonitoringEntity.CONTAINERS]: 'Container',
[InfraMonitoringEntity.JOBS]: 'Job',
[InfraMonitoringEntity.VOLUMES]: 'Volume',
};
export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
{
[InfraMonitoringEntity.HOSTS]:
@@ -939,9 +976,11 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
PAGE_SIZE: 'pageSize',
EXPANDED: 'expanded',
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CATEGORY: 'selectedItemCategory',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
OVERVIEW_EXPRESSION: 'overviewExpression',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',

View File

@@ -18,7 +18,6 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
VIEWS,
} from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
@@ -85,10 +84,13 @@ export const useInfraMonitoringGroupBy = (): UseQueryStateReturn<
parseAsGroupBy.withDefault([]).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringView = (): UseQueryStateReturn<string, string> =>
export const useInfraMonitoringView = (): UseQueryStateReturn<
string | null,
undefined
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW,
parseAsString.withDefault(VIEWS.METRICS).withOptions(defaultNuqsOptions),
parseAsString.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringLogFilters = (): UseQueryStateReturn<
@@ -137,6 +139,8 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
export interface SelectedItemParams {
selectedItem: string | null;
/** Drawer category, when it differs from the list page category. */
category?: string | null;
clusterName?: string | null;
namespaceName?: string | null;
containerName?: string | null;
@@ -144,6 +148,7 @@ export interface SelectedItemParams {
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
@@ -165,6 +170,8 @@ export const useInfraMonitoringSelectedItemParams =
() => ({
selectedItem:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
category:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY] ?? null,
clusterName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
null,
@@ -183,6 +190,7 @@ export const useInfraMonitoringSelectedItemParams =
if (newParams === null) {
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
@@ -193,6 +201,8 @@ export const useInfraMonitoringSelectedItemParams =
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
newParams.selectedItem ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]:
newParams.category ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -34,6 +33,8 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
@@ -58,12 +59,16 @@ function ListView({
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const {
selectedTime: globalSelectedTime,
@@ -92,7 +97,6 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
orderBy,
],
[
stagedQuery,
@@ -101,7 +105,6 @@ function ListView({
paginationConfig,
maxTime,
minTime,
orderBy,
],
);
@@ -167,9 +170,12 @@ function ListView({
[queryTableData],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -23,6 +22,7 @@ import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
@@ -57,9 +57,13 @@ function TracesView({
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const orderBy = useMemo(
() => getListOrderBy(stagedQuery, TRACE_VIEW_DEFAULT_ORDER_BY),
[stagedQuery],
);
const {
columns,
@@ -94,7 +98,6 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -103,7 +106,6 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -165,9 +167,12 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(

View File

@@ -1,6 +1,7 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -11,12 +12,7 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -16,14 +16,14 @@ function LogsActionsContainer({
showFrequencyChart,
handleToggleFrequencyChart,
orderBy,
setOrderBy,
onOrderChange,
explorerActions,
}: {
listQuery: any;
showFrequencyChart: boolean;
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
onOrderChange: (value: string) => void;
explorerActions: ReactNode;
}): JSX.Element {
const { options, config } = useOptionsMenu({
@@ -78,7 +78,7 @@ function LogsActionsContainer({
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
onChange={onOrderChange}
dataSource={DataSource.LOGS}
/>
</div>

View File

@@ -59,6 +59,7 @@ import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import LogsActionsContainer from './LogsActionsContainer';
@@ -101,7 +102,8 @@ function LogsExplorerViewsContainer({
const currentMinTimeRef = useRef<number>(minTime);
// Context
const { stagedQuery, panelType } = useQueryBuilder();
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const selectedPanelType = panelType || PANEL_TYPES.LIST;
@@ -111,7 +113,7 @@ function LogsExplorerViewsContainer({
const [requestData, setRequestData] = useState<Query | null>(null);
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
@@ -192,6 +194,13 @@ function LogsExplorerViewsContainer({
'custom',
);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const getRequestData = useCallback(
(
query: Query | null,
@@ -430,7 +439,7 @@ function LogsExplorerViewsContainer({
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
onOrderChange={handleOrderChange}
explorerActions={explorerActions}
/>
)}

View File

@@ -7,7 +7,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
@@ -34,6 +33,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
@@ -66,12 +66,16 @@ function ListView({
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const {
selectedTime: globalSelectedTime,
@@ -123,7 +127,6 @@ function ListView({
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
@@ -133,7 +136,6 @@ function ListView({
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
@@ -215,9 +217,12 @@ function ListView({
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -2,6 +2,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -12,12 +13,7 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -24,6 +24,7 @@ export default function Legend({
focusedSeriesIndex,
onAction,
showCopy = true,
itemLabel = 'series',
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [filterQuery, setFilterQuery] = useState('');
@@ -92,11 +93,12 @@ export default function Legend({
position={position}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
itemLabel={itemLabel}
/>
)}
{isEmptyState ? (
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
No {itemLabel} found matching &quot;{effectiveQuery}&quot;
</div>
) : (
<VirtuosoGrid

View File

@@ -14,6 +14,7 @@ export interface LegendToolbarProps {
position: LegendPosition;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
itemLabel: string;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
@@ -23,6 +24,7 @@ export default function LegendToolbar({
position,
filterQuery,
onFilterQueryChange,
itemLabel,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
@@ -48,7 +50,7 @@ export default function LegendToolbar({
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
{`Showing ${visibleCount} of ${totalCount} ${itemLabel}`}
</span>
);

View File

@@ -168,6 +168,8 @@ export interface LegendProps {
onAction: OnLegendAction;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
/** Plural noun for the items in the search readouts. Default 'series'. */
itemLabel?: string;
}
/** Props for the uPlot legend controller, which derives items + interaction

View File

@@ -63,20 +63,20 @@ export default function ChartWrapper({
);
const legendComponent = useCallback(
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
(layout: LegendLayout): React.ReactNode => {
if (!showLegend) {
return null;
}
// A pie's slices and a heatmap's groups are not uPlot series.
if (customLegend) {
return customLegend(averageLegendWidth);
return customLegend(layout);
}
return (
<UPlotLegend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
averageLegendWidth={layout.averageLegendWidth}
showSearch={layout.showSearch}
/>
);
},

View File

@@ -26,6 +26,7 @@ import {
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { HeatmapChartProps } from 'lib/visualization/charts/types';
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import {
buildHeatmapConfig,
@@ -277,11 +278,13 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
}, []);
const groupLegend = useCallback(
(averageLegendWidth: number): React.ReactNode => (
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
<Legend
items={legendItems}
position={legendPosition}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
itemLabel="groups"
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>

View File

@@ -9,6 +9,7 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
import type {
HeatmapAxisScale,
HeatmapCell,
@@ -41,7 +42,7 @@ interface BaseChartProps {
tooltipPortalRoot?: HTMLElement | null;
/** Replaces the config-driven legend, for charts whose legend lists something
* other than uPlot series — heatmap groups, where the series are bucket rows. */
customLegend?: (averageLegendWidth: number) => React.ReactNode;
customLegend?: (layout: LegendLayout) => React.ReactNode;
/** Measured against for the chart/legend split. Pair with `customLegend`. */
legendLabels?: string[];
/** Rendered under the plot but above the legend, inside the chart column. */

View File

@@ -27,14 +27,31 @@ import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
showTraceDetailsHeaderOptions?: boolean;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
}
const SKELETON_COUNT = 3;
@@ -56,19 +73,20 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
showTraceDetailsHeaderOptions,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
const handleToggleAnalytics = useCallback((): void => {
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
@@ -98,11 +116,8 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
return (
@@ -122,12 +137,12 @@ function TraceDetailsHeader({
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID}
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
)}
{showTraceDetailsHeaderOptions && traceSummary && (
{isDataLoaded && (
<div
className={cx(
styles.filterSection,
@@ -155,10 +170,10 @@ function TraceDetailsHeader({
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID}
startTime={startTime}
endTime={endTime}
totalSpansCount={traceSummary.totalSpansCount}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
/>
</div>
</TooltipProvider>
@@ -168,9 +183,9 @@ function TraceDetailsHeader({
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={startTime}
endTime={endTime}
traceID={traceID}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
@@ -183,18 +198,18 @@ function TraceDetailsHeader({
{showTraceDetails && (
<div className={styles.subHeader}>
{traceSummary ? (
{traceMetadata ? (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
statusCode={traceMetadata.rootSpanStatusCode}
/>
) : (
<DetailsLoader />
@@ -202,7 +217,7 @@ function TraceDetailsHeader({
</div>
)}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -5,11 +5,6 @@ import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
jest.mock('../useTraceSummary', () => ({
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
}));
const mockGoBack = jest.fn();
const mockPush = jest.fn();
@@ -56,19 +51,13 @@ jest.mock('components/FieldsSelector', () => ({
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
showTraceDetailsHeaderOptions: false,
};
const SUMMARY = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000,
rootServiceName: 'frontend',
rootServiceEntryPoint: 'GET /checkout',
rootSpanStatusCode: '200',
hasMissingSpans: false,
totalSpansCount: 3,
totalErrorSpansCount: 0,
isDataLoaded: false,
};
describe('TraceDetailsHeader – back button', () => {
@@ -103,32 +92,10 @@ describe('TraceDetailsHeader – back button', () => {
describe('TraceDetailsHeader – action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: SUMMARY, isLoading: false });
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
it('does not render the action buttons until the summary loads', () => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: true });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
});
it('does not render the action buttons while data is still loading', () => {
render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
);
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -139,7 +106,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -150,7 +117,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -166,7 +133,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// useTraceSummary is mocked, so no API call is made.
// Plain prop, no API mock needed: traceMetadata is passed straight in.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
@@ -175,20 +142,16 @@ describe('TraceDetailsHeader – trace metadata row', () => {
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
};
const mockSummary = (data?: typeof traceMetadata): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
afterEach(() => {
mockSummary(undefined);
});
it('renders the metadata (service, entry point, duration, status) when provided', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
@@ -203,8 +166,13 @@ describe('TraceDetailsHeader – trace metadata row', () => {
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
@@ -224,12 +192,9 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('shows skeletons instead of the metadata when the summary is absent', () => {
const { container } = render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />,
);
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});

View File

@@ -1,16 +0,0 @@
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
interface UseTraceSummaryResult {
data: SpantypesGettableTraceSummaryDTO | undefined;
isLoading: boolean;
}
export function useTraceSummary(traceId: string): UseTraceSummaryResult {
const { data, isLoading } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, keepPreviousData: true } },
);
return { data: data?.data, isLoading };
}

View File

@@ -28,6 +28,7 @@ import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
@@ -322,6 +323,38 @@ function TraceDetailsV3(): JSX.Element {
[],
);
const filterMetadata = useMemo(
() => ({
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
traceId: traceId || '',
}),
[
traceData?.payload?.startTimestampMillis,
traceData?.payload?.endTimestampMillis,
traceId,
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -360,10 +393,10 @@ function TraceDetailsV3(): JSX.Element {
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
{showNoData ? (

View File

@@ -44,7 +44,6 @@ import {
traceDetailFieldKeys,
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -150,13 +149,6 @@ export const traceDetailsMocks = defineStoryMocks({
),
),
rest.get(
'http://localhost/api/v1/traces/:traceId/summary',
response.json(() =>
traceSummaryResponse({ ...trace, missingSpans: values.missingSpans }),
),
),
rest.post(
'http://localhost/api/v3/traces/:traceId/flamegraph',
response.json(() => traceFlamegraphResponse(trace)),

View File

@@ -6,7 +6,6 @@
import type {
GetFlamegraph200,
GetTraceAggregations200,
GetTraceSummary200,
GetWaterfallV4200,
SpantypesFlamegraphSpanDTO,
SpantypesSpanAggregationDTO,
@@ -304,27 +303,6 @@ export const traceWaterfallResponse = (
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
const spans = buildSpans(options);
const root = spans[0];
return {
status: 'success',
data: {
startTimestampMillis: Math.round(options.traceStart),
endTimestampMillis: Math.round(options.traceStart + ROOT_DURATION_MS),
rootServiceName: root?.template.service ?? '',
rootServiceEntryPoint: root?.template.name ?? '',
rootSpanStatusCode: root?.hasError ? '503' : '200',
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
},
};
};
const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
spanId: span.spanId,
parentSpanId: span.parentSpanId,

View File

@@ -66,6 +66,7 @@ import {
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
currentQuery: initialQueriesMap.metrics,
@@ -182,6 +183,11 @@ export function QueryBuilderProvider({
})) ?? [],
};
const urlPanelType = readPanelTypeFromUrl(PANEL_TYPES.LIST);
const isRawPanel =
urlPanelType === PANEL_TYPES.LIST || urlPanelType === PANEL_TYPES.TRACE;
const setupedQueryData = builder.queryData.map((item) => {
const currentElement: IBuilderQuery = {
...item,
@@ -200,8 +206,10 @@ export function QueryBuilderProvider({
// Explorer pages: sanitize stale orderBy before first query
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
(location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER ||
location.pathname === ROUTES.AI_OBSERVABILITY_EXPLORER) &&
!isRawPanel;
if (isExplorer) {
const sanitizedOrderBy = sanitizeOrderByForExplorer(currentElement);
return calledFromHandleRunQuery
@@ -262,6 +270,8 @@ export function QueryBuilderProvider({
setStagedQuery(nextQuery);
setCurrentQuery(newQueryState);
setQueryType(type);
setCalledFromHandleRunQuery(false);
},
[prepareQueryBuilderData],
);
@@ -1038,7 +1048,8 @@ export function QueryBuilderProvider({
const handleRunQuery = useCallback(() => {
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
location.pathname === ROUTES.TRACES_EXPLORER ||
location.pathname === ROUTES.AI_OBSERVABILITY_EXPLORER;
if (isExplorer) {
setCalledFromHandleRunQuery(true);
}

View File

@@ -0,0 +1,186 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
const timestampOrderBy = [{ columnName: 'timestamp', order: 'desc' }];
const queryWithTimestampOrder = (base: Query): Query => ({
...base,
builder: {
...base.builder,
queryData: base.builder.queryData.map((item) => ({
...item,
orderBy: timestampOrderBy,
})),
},
});
const renderOn = (
route: string,
panelType: PANEL_TYPES | null,
): ReturnType<typeof renderHook> => {
const search = new URLSearchParams();
if (panelType) {
search.set('panelTypes', JSON.stringify(panelType));
}
__setSearchParamsGetterForTest(() => search);
return renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={route}>{children}</AllTheProviders>
),
});
};
const initOn = (
route: string,
panelType: PANEL_TYPES | null,
query: Query,
): Query | null => {
const search = new URLSearchParams();
if (panelType) {
search.set('panelTypes', JSON.stringify(panelType));
}
__setSearchParamsGetterForTest(() => search);
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={route}>{children}</AllTheProviders>
),
});
act(() => {
result.current.initQueryBuilderData(query);
});
return result.current.stagedQuery;
};
describe('explorer orderBy sanitizing by panel type', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it('keeps the order on the logs list panel', () => {
const staged = initOn(
ROUTES.LOGS_EXPLORER,
PANEL_TYPES.LIST,
queryWithTimestampOrder(initialQueriesMap.logs),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
it('keeps the order when panelTypes is absent, since the explorers default to list', () => {
const staged = initOn(
ROUTES.LOGS_EXPLORER,
null,
queryWithTimestampOrder(initialQueriesMap.logs),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
it('keeps the order on the traces trace panel', () => {
const staged = initOn(
ROUTES.TRACES_EXPLORER,
PANEL_TYPES.TRACE,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
// engineering-pod#2813: a timestamp order carried from the list view into the
// time series tab is not a group-by key or an aggregation, and errors the query.
it('drops the order on the time series panel', () => {
const staged = initOn(
ROUTES.LOGS_EXPLORER,
PANEL_TYPES.TIME_SERIES,
queryWithTimestampOrder(initialQueriesMap.logs),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
});
it('keeps the order on the LLM observability list panel', () => {
const staged = initOn(
ROUTES.AI_OBSERVABILITY_EXPLORER,
PANEL_TYPES.LIST,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
it('drops the order on the LLM observability time series panel', () => {
const staged = initOn(
ROUTES.AI_OBSERVABILITY_EXPLORER,
PANEL_TYPES.TIME_SERIES,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
});
it('drops the order on the table panel', () => {
const staged = initOn(
ROUTES.TRACES_EXPLORER,
PANEL_TYPES.TABLE,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
});
});
// handleRunQuery means "respect the query I just built" for the init it causes.
// It used to stay set for the rest of the visit, so every later init skipped
// sanitizing as well.
describe('the run bypass is one-shot', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it('skips sanitizing for the init after a run, and sanitizes the next one', () => {
const { result } = renderOn(ROUTES.LOGS_EXPLORER, PANEL_TYPES.TIME_SERIES);
act(() => {
(result.current as ReturnType<typeof useQueryBuilder>).handleRunQuery();
});
const afterRun = {
...queryWithTimestampOrder(initialQueriesMap.logs),
id: 'run-1',
};
act(() => {
(result.current as ReturnType<typeof useQueryBuilder>).initQueryBuilderData(
afterRun,
);
});
expect(
(result.current as ReturnType<typeof useQueryBuilder>).stagedQuery?.builder
.queryData[0].orderBy,
).toStrictEqual(timestampOrderBy);
const next = {
...queryWithTimestampOrder(initialQueriesMap.logs),
id: 'run-2',
};
act(() => {
(result.current as ReturnType<typeof useQueryBuilder>).initQueryBuilderData(
next,
);
});
expect(
(result.current as ReturnType<typeof useQueryBuilder>).stagedQuery?.builder
.queryData[0].orderBy,
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,138 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
DEFAULT_LIST_ORDER_BY,
getListOrderBy,
getRawPanelOrderBy,
parseListOrderBy,
setListOrderBy,
} from 'utils/explorerUtils';
const withOrderBy = (
orderBy: { columnName: string; order: string }[],
): Query => ({
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: initialQueriesMap.logs.builder.queryData.map((item) => ({
...item,
orderBy,
})),
},
});
describe('getRawPanelOrderBy', () => {
it('keeps row fields', () => {
expect(
getRawPanelOrderBy(
withOrderBy([{ columnName: 'duration_nano', order: 'desc' }]),
),
).toStrictEqual([{ columnName: 'duration_nano', order: 'desc' }]);
});
it('keeps a group-by key, which is a row field too', () => {
expect(
getRawPanelOrderBy(
withOrderBy([{ columnName: 'service.name', order: 'asc' }]),
),
).toStrictEqual([{ columnName: 'service.name', order: 'asc' }]);
});
// Repro: set an order on the Time Series tab, then switch to List.
it('drops an aggregation carried in from an aggregated panel', () => {
expect(
getRawPanelOrderBy(withOrderBy([{ columnName: 'count()', order: 'desc' }])),
).toStrictEqual([]);
});
it('drops the aggregated value sentinel', () => {
expect(
getRawPanelOrderBy(
withOrderBy([{ columnName: '#SIGNOZ_VALUE', order: 'desc' }]),
),
).toStrictEqual([]);
});
it('keeps the usable entry and drops the rest', () => {
expect(
getRawPanelOrderBy(
withOrderBy([
{ columnName: 'count()', order: 'desc' },
{ columnName: 'timestamp', order: 'desc' },
]),
),
).toStrictEqual([{ columnName: 'timestamp', order: 'desc' }]);
});
});
describe('getListOrderBy', () => {
it('falls back to the default when the query holds only an aggregation', () => {
expect(
getListOrderBy(withOrderBy([{ columnName: 'count()', order: 'desc' }])),
).toBe(DEFAULT_LIST_ORDER_BY);
});
it('reads the first order entry as a select value', () => {
expect(
getListOrderBy(withOrderBy([{ columnName: 'timestamp', order: 'asc' }])),
).toBe('timestamp:asc');
});
it('lower-cases a hand-written direction from a deep link', () => {
expect(
getListOrderBy(withOrderBy([{ columnName: 'timestamp', order: 'ASC' }])),
).toBe('timestamp:asc');
});
it('shows the primary entry when the query also carries the id tiebreaker', () => {
const query = withOrderBy([
{ columnName: 'timestamp', order: 'desc' },
{ columnName: 'id', order: 'desc' },
]);
expect(getListOrderBy(query)).toBe('timestamp:desc');
});
it('falls back to the default when the query carries no order', () => {
expect(getListOrderBy(withOrderBy([]))).toBe(DEFAULT_LIST_ORDER_BY);
expect(getListOrderBy(null)).toBe(DEFAULT_LIST_ORDER_BY);
});
it('falls back to the given default', () => {
expect(getListOrderBy(withOrderBy([]), 'last_activity_time:desc')).toBe(
'last_activity_time:desc',
);
});
});
describe('parseListOrderBy', () => {
it('splits a select value into an order payload', () => {
expect(parseListOrderBy('durationNano:asc')).toStrictEqual({
columnName: 'durationNano',
order: 'asc',
});
});
});
describe('setListOrderBy', () => {
it('replaces the order on every queryData entry', () => {
const base = withOrderBy([{ columnName: 'timestamp', order: 'desc' }]);
const next = setListOrderBy(base, 'severity_text:asc');
next.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual([
{ columnName: 'severity_text', order: 'asc' },
]);
});
});
it('does not mutate the query it is given', () => {
const base = withOrderBy([{ columnName: 'timestamp', order: 'desc' }]);
setListOrderBy(base, 'timestamp:asc');
expect(base.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
});

View File

@@ -0,0 +1,46 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
const withParam = (value?: string): void => {
const search = new URLSearchParams();
if (value !== undefined) {
search.set('panelTypes', value);
}
__setSearchParamsGetterForTest(() => search);
};
describe('readPanelTypeFromUrl', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it('decodes the JSON-encoded value the app writes', () => {
withParam(JSON.stringify(PANEL_TYPES.TIME_SERIES));
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.TIME_SERIES);
});
it('accepts an unencoded value from a hand-written link', () => {
withParam('list');
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.LIST);
});
// engineering-pod#6158: an unguarded parse of this param crashed the page.
it('returns the raw value instead of throwing on a malformed one', () => {
withParam('{list');
expect(readPanelTypeFromUrl()).toBe('{list');
});
it('returns the fallback when the param is absent', () => {
withParam();
expect(readPanelTypeFromUrl(PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
expect(readPanelTypeFromUrl()).toBeNull();
});
});

View File

@@ -1,8 +1,72 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getParsedAggregationOptionsForOrderBy } from './aggregationConverter';
export const DEFAULT_LIST_ORDER_BY = 'timestamp:desc';
// Filters only. The query keeps the order for the panel that can use it.
export const getRawPanelOrderBy = (query: Query | null): OrderByPayload[] => {
const queryData = query?.builder?.queryData?.[0];
if (!queryData) {
return [];
}
const disallowed = new Set<string>([SIGNOZ_VALUE]);
getParsedAggregationOptionsForOrderBy(queryData).forEach((aggregation) => {
if (aggregation?.key) {
disallowed.add(aggregation.key);
}
});
return (queryData.orderBy ?? []).filter(
(item) => !disallowed.has(item.columnName),
);
};
// Never write the fallback back: a view saved without an order would then read
// as unsaved on open.
export const getListOrderBy = (
query: Query | null,
fallback: string = DEFAULT_LIST_ORDER_BY,
): string => {
const [orderBy] = getRawPanelOrderBy(query);
if (!orderBy?.columnName || !orderBy?.order) {
return fallback;
}
return `${orderBy.columnName}:${orderBy.order.toLowerCase()}`;
};
export const parseListOrderBy = (orderBy: string): OrderByPayload => {
const [columnName, order] = orderBy.split(':');
return {
columnName: columnName || 'timestamp',
order: order || 'desc',
};
};
export const setListOrderBy = (query: Query, orderBy: string): Query => {
const nextOrderBy = [parseListOrderBy(orderBy)];
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...item,
orderBy: nextOrderBy,
})),
},
};
};
// Mapping between panel types and explorer views
export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {

View File

@@ -0,0 +1,25 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getUnstableCurrentSearchParams } from './getUnstableCurrentSearchParams';
/**
* The param is JSON-encoded, but a hand-written link can carry it unencoded, and
* a malformed one must not throw (engineering-pod#6158). Reads the params
* directly, not through `useUrlQuery`, so it works outside a hook.
*/
export const readPanelTypeFromUrl = (
fallback: PANEL_TYPES | null = null,
): PANEL_TYPES | null => {
const raw = getUnstableCurrentSearchParams().get(QueryParams.panelTypes);
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw) as PANEL_TYPES;
} catch {
return raw as PANEL_TYPES;
}
};