mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 19:20:42 +01:00
Compare commits
6 Commits
fix/trace-
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f883a8a8 | ||
|
|
fe8aebc15a | ||
|
|
0ae98b05c5 | ||
|
|
7c76ba8d9e | ||
|
|
104758f3cf | ||
|
|
504be0dfd7 |
@@ -21,6 +21,7 @@ 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';
|
||||
@@ -29,7 +30,6 @@ 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,71 +729,65 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TruncatedTooltip
|
||||
<div
|
||||
key={option.value || `option-${index}`}
|
||||
title={String(option.label || '')}
|
||||
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}
|
||||
>
|
||||
{(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"
|
||||
<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"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<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>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -16,7 +16,6 @@ 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';
|
||||
@@ -26,7 +25,6 @@ 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,
|
||||
@@ -209,46 +207,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
@@ -709,67 +702,50 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
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]);
|
||||
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],
|
||||
);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
// 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>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
@@ -1,34 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -135,51 +135,6 @@ 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
|
||||
|
||||
@@ -29,13 +29,6 @@ $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
|
||||
@@ -230,33 +223,6 @@ $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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
InframonitoringtypesHostRecordDTO,
|
||||
InframonitoringtypesHostStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
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 {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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 } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { ArrowLeft, X } from '@signozhq/icons';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
@@ -16,19 +16,12 @@ import {
|
||||
useGlobalTimeStore,
|
||||
} from 'store/globalTime';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
K8S_CATEGORY_SINGULAR_LABELS,
|
||||
} from '../constants';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } 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';
|
||||
|
||||
@@ -93,11 +86,6 @@ export default function K8sBaseDetails<T>({
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
|
||||
const selectedItemExpression = useMemo(
|
||||
() => getSelectedItemExpression(selectedItemParams),
|
||||
[getSelectedItemExpression, selectedItemParams],
|
||||
);
|
||||
|
||||
const entityQueryKey = useMemo(
|
||||
() =>
|
||||
getAutoRefreshQueryKey(
|
||||
@@ -133,10 +121,9 @@ 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);
|
||||
return fetchEntityData(
|
||||
{ filter: { expression: selectedItemExpression }, start, end },
|
||||
signal,
|
||||
);
|
||||
const expression = getSelectedItemExpression(selectedItemParams);
|
||||
|
||||
return fetchEntityData({ filter: { expression }, start, end }, signal);
|
||||
},
|
||||
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
|
||||
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
|
||||
@@ -171,29 +158,9 @@ 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);
|
||||
}, [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]);
|
||||
}, [setSelectedItemParams]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean): void => {
|
||||
@@ -205,25 +172,11 @@ export default function K8sBaseDetails<T>({
|
||||
);
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
toast.success(
|
||||
`${K8S_CATEGORY_SINGULAR_LABELS[category]} name copied to clipboard`,
|
||||
{ position: 'bottom-left' },
|
||||
);
|
||||
}, [category]);
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}, []);
|
||||
|
||||
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, {
|
||||
@@ -234,48 +187,20 @@ 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 = (
|
||||
<>
|
||||
{/* 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 />}
|
||||
/>
|
||||
)}
|
||||
<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') ||
|
||||
@@ -283,8 +208,8 @@ export default function K8sBaseDetails<T>({
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
<CopyButton
|
||||
value={entityName || selectedItem || ''}
|
||||
ariaLabel={`Copy ${K8S_CATEGORY_SINGULAR_LABELS[category]} name`}
|
||||
value={selectedItem ?? ''}
|
||||
ariaLabel="Copy ID"
|
||||
className={styles.copyIdButton}
|
||||
testId="copy-id-button"
|
||||
onCopy={handleCopyId}
|
||||
@@ -346,8 +271,6 @@ export default function K8sBaseDetails<T>({
|
||||
customTabs={customTabs}
|
||||
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
|
||||
eventsInitialExpression={eventsInitialExpression}
|
||||
entityAttributes={entityAttributes}
|
||||
entityName={entityName}
|
||||
/>
|
||||
</GlobalTimeProvider>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
LayoutGrid,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
@@ -32,7 +31,6 @@ 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 {
|
||||
@@ -69,16 +67,12 @@ 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,
|
||||
@@ -92,9 +86,6 @@ 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);
|
||||
}
|
||||
@@ -113,28 +104,14 @@ export default function K8sBaseDetailsContent<T>({
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
const defaultView = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hideDetailViewTabs &&
|
||||
selectedView &&
|
||||
!validTabs.includes(selectedView)
|
||||
) {
|
||||
void setSelectedView(defaultView);
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [
|
||||
hideDetailViewTabs,
|
||||
selectedView,
|
||||
validTabs,
|
||||
defaultView,
|
||||
setSelectedView,
|
||||
]);
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
|
||||
// An absent view param lands on the first tab the category offers
|
||||
const effectiveView = hideDetailViewTabs
|
||||
? VIEW_TYPES.METRICS
|
||||
: (selectedView ?? defaultView);
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
// 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
|
||||
@@ -192,20 +169,20 @@ export default function K8sBaseDetailsContent<T>({
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: effectiveView,
|
||||
view: selectedView,
|
||||
});
|
||||
|
||||
logInfraExplorerNavigatedEvent({
|
||||
entityType: category,
|
||||
destination:
|
||||
effectiveView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
|
||||
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
|
||||
source: 'tab_cta_button',
|
||||
tab: effectiveView,
|
||||
tab: selectedView,
|
||||
sourceKey: null,
|
||||
drawerDurationMsAtNavigation: getDrawerDurationMs(),
|
||||
});
|
||||
|
||||
if (effectiveView === VIEW_TYPES.LOGS) {
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
@@ -230,7 +207,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (effectiveView === VIEW_TYPES.TRACES) {
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
@@ -287,7 +264,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
entityType={category}
|
||||
activeTab={effectiveView}
|
||||
activeTab={selectedView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,21 +275,8 @@ export default function K8sBaseDetailsContent<T>({
|
||||
type="single"
|
||||
className={styles.viewsTabs}
|
||||
onChange={handleTabChange}
|
||||
value={effectiveView}
|
||||
value={selectedView}
|
||||
items={[
|
||||
...(tabVisibility.showOverview
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className={styles.viewTitle}>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
@@ -377,7 +341,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
]}
|
||||
/>
|
||||
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -390,7 +354,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -406,16 +370,6 @@ 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}
|
||||
@@ -452,7 +406,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
effectiveView === tab.key ? (
|
||||
selectedView === tab.key ? (
|
||||
<Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
|
||||
@@ -39,7 +39,6 @@ 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';
|
||||
@@ -302,8 +301,6 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { useInfraMonitoringCategory } from '../hooks';
|
||||
import { getEntityConfig } from './entity.registry';
|
||||
import { K8sBaseList } from './K8sBaseList';
|
||||
import K8sBaseDetails from './K8sBaseDetails';
|
||||
@@ -18,35 +15,27 @@ 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
|
||||
{...config.list}
|
||||
{...list}
|
||||
controlListPrefix={controlListPrefix}
|
||||
leftFilters={leftFilters}
|
||||
/>
|
||||
|
||||
{/* No key: remounting would animate the drawer shut and open again
|
||||
for what is really a change of contents */}
|
||||
{drawerConfig && <K8sBaseDetails {...drawerConfig.details} />}
|
||||
<K8sBaseDetails {...details} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { resetDrawerHistory } from './useDrawerHistoryStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
|
||||
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
|
||||
@@ -220,8 +219,6 @@ 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);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ type TestEntity = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
cluster: string;
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
|
||||
const mockEntity: TestEntity = {
|
||||
@@ -30,22 +29,14 @@ const mockEntity: TestEntity = {
|
||||
cluster: 'test-cluster',
|
||||
};
|
||||
|
||||
/** 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) {
|
||||
function createBaseProps() {
|
||||
return {
|
||||
category,
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
|
||||
fetchEntityData: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null }),
|
||||
getEntityName: (e: TestEntity): string => e.name,
|
||||
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
@@ -60,10 +51,7 @@ function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
|
||||
|
||||
interface RenderOptions {
|
||||
view?: string;
|
||||
category?: InfraMonitoringEntity;
|
||||
entity?: TestEntity;
|
||||
tabsConfig?: {
|
||||
showOverview?: boolean;
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
@@ -79,9 +67,6 @@ 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 = {}) {
|
||||
@@ -93,7 +78,7 @@ function renderK8sBaseDetails({
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
{...createBaseProps(category, entity)}
|
||||
{...createBaseProps()}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
@@ -121,25 +106,6 @@ 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({
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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('');
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -3,11 +3,18 @@ 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 { InfraMonitoringEntity } from '../../../constants';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../../constants';
|
||||
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
|
||||
import { buildK8sListNavigationUrl } from '../../utils';
|
||||
import styles from './EntityCountsSection.module.scss';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
@@ -48,6 +55,70 @@ 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) => (
|
||||
@@ -76,7 +147,7 @@ export function EntityCountsSection<T>({
|
||||
<TextNoData type="typography" className={styles.countValue} />
|
||||
)}
|
||||
<Link
|
||||
to={buildK8sListNavigationUrl(config.targetCategory, filterExpression)}
|
||||
to={buildNavigationUrl(config.targetCategory)}
|
||||
onClick={(): void => handleCardNavigate(config.label)}
|
||||
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
|
||||
@@ -97,11 +97,7 @@ export function logInfraExplorerNavigatedEvent(params: {
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source:
|
||||
| 'chart_compass_icon'
|
||||
| 'tab_cta_button'
|
||||
| 'stats_card'
|
||||
| 'overview_cta';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
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 ');
|
||||
}
|
||||
@@ -75,8 +75,6 @@ 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;
|
||||
@@ -143,10 +141,6 @@ 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
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
@@ -1,47 +0,0 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,7 @@ import {
|
||||
formatValueForExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
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';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
export function sortByColumnOrder<T>(
|
||||
items: T[],
|
||||
@@ -187,74 +178,3 @@ 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()}`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -123,7 +123,6 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CLUSTERS,
|
||||
eventCategory: InfraMonitoringEvents.Cluster,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'cluster',
|
||||
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -127,7 +127,6 @@ export const containerEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'container',
|
||||
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box } from '@signozhq/icons';
|
||||
import { Container } 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={<Box data-hide-expanded="true" size={14} />}
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,7 +132,6 @@ export const daemonSetEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DAEMONSETS,
|
||||
eventCategory: InfraMonitoringEvents.DaemonSet,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'daemonset',
|
||||
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -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/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -147,7 +147,6 @@ export const deploymentEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
eventCategory: InfraMonitoringEvents.Deployment,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'deployment',
|
||||
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -1,343 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Container } from '@signozhq/icons';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import { CustomTab } from '../Base/types';
|
||||
import { CustomTab } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
podUtilizationByPodWidgetInfo,
|
||||
|
||||
@@ -26,10 +26,7 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-8);
|
||||
margin: 0;
|
||||
// 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%);
|
||||
min-width: 900px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,29 +34,6 @@
|
||||
--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);
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
InfraMonitoringEntity,
|
||||
VIEWS,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
@@ -18,7 +15,7 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: `${VIEWS}`,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
|
||||
@@ -12,11 +12,23 @@ import {
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ArrowUpToLine,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
FilePenLine,
|
||||
Filter,
|
||||
Group,
|
||||
HardDrive,
|
||||
Workflow,
|
||||
} 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,
|
||||
@@ -30,7 +42,6 @@ import {
|
||||
GetStatefulsetsQuickFiltersConfig,
|
||||
GetVolumesQuickFiltersConfig,
|
||||
InfraMonitoringEntity,
|
||||
K8S_CATEGORY_LABELS,
|
||||
K8sCategories,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from './constants';
|
||||
@@ -145,62 +156,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.CONTAINERS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CONTAINERS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.CONTAINERS} />,
|
||||
label: 'Containers',
|
||||
icon: <Box size={14} />,
|
||||
config: GetContainersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.PODS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.PODS} />,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NODES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.NODES} />,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NAMESPACES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.NAMESPACES} />,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CLUSTERS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.CLUSTERS} />,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DEPLOYMENTS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.DEPLOYMENTS} />,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.JOBS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.JOBS} />,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DAEMONSETS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.DAEMONSETS} />,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.STATEFULSETS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.STATEFULSETS} />,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.VOLUMES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.VOLUMES} />,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,7 +132,6 @@ export const jobEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.JOBS,
|
||||
eventCategory: InfraMonitoringEvents.Job,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'job',
|
||||
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,7 +6,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -143,7 +143,6 @@ export const namespaceEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.NAMESPACES,
|
||||
eventCategory: InfraMonitoringEvents.Namespace,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'namespace',
|
||||
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ export const nodeEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
eventCategory: InfraMonitoringEvents.Node,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'node',
|
||||
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -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/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
|
||||
@@ -121,7 +121,6 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
|
||||
details: {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'pod',
|
||||
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -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/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,7 +132,6 @@ export const statefulSetEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.STATEFULSETS,
|
||||
eventCategory: InfraMonitoringEvents.StatefulSet,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'statefulSet',
|
||||
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -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/types';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
|
||||
@@ -127,7 +127,6 @@ export const volumeEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.VOLUMES,
|
||||
eventCategory: InfraMonitoringEvents.Volume,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'volume',
|
||||
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -157,11 +157,9 @@ 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,
|
||||
@@ -898,41 +896,6 @@ 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]:
|
||||
@@ -976,11 +939,9 @@ 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',
|
||||
|
||||
@@ -18,6 +18,7 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import {
|
||||
DEFAULT_K8S_CATEGORY,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
VIEWS,
|
||||
} from './constants';
|
||||
import { orderBySchema, OrderBySchemaType } from './schemas';
|
||||
|
||||
@@ -84,13 +85,10 @@ export const useInfraMonitoringGroupBy = (): UseQueryStateReturn<
|
||||
parseAsGroupBy.withDefault([]).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringView = (): UseQueryStateReturn<
|
||||
string | null,
|
||||
undefined
|
||||
> =>
|
||||
export const useInfraMonitoringView = (): UseQueryStateReturn<string, string> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW,
|
||||
parseAsString.withOptions(defaultNuqsOptions),
|
||||
parseAsString.withDefault(VIEWS.METRICS).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringLogFilters = (): UseQueryStateReturn<
|
||||
@@ -139,8 +137,6 @@ 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;
|
||||
@@ -148,7 +144,6 @@ 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,
|
||||
@@ -170,8 +165,6 @@ 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,
|
||||
@@ -190,7 +183,6 @@ 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,
|
||||
@@ -201,8 +193,6 @@ 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]:
|
||||
|
||||
@@ -24,7 +24,6 @@ export default function Legend({
|
||||
focusedSeriesIndex,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
itemLabel = 'series',
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
@@ -93,12 +92,11 @@ export default function Legend({
|
||||
position={position}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
itemLabel={itemLabel}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className={styles.emptyState}>
|
||||
No {itemLabel} found matching "{effectiveQuery}"
|
||||
No series found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
|
||||
@@ -14,7 +14,6 @@ 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. */
|
||||
@@ -24,7 +23,6 @@ export default function LegendToolbar({
|
||||
position,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
itemLabel,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
@@ -50,7 +48,7 @@ export default function LegendToolbar({
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} ${itemLabel}`}
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -168,8 +168,6 @@ 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
|
||||
|
||||
@@ -63,20 +63,20 @@ export default function ChartWrapper({
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(layout: LegendLayout): React.ReactNode => {
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// A pie's slices and a heatmap's groups are not uPlot series.
|
||||
if (customLegend) {
|
||||
return customLegend(layout);
|
||||
return customLegend(averageLegendWidth);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={layout.averageLegendWidth}
|
||||
showSearch={layout.showSearch}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -278,13 +277,11 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -42,7 +41,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?: (layout: LegendLayout) => React.ReactNode;
|
||||
customLegend?: (averageLegendWidth: number) => 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. */
|
||||
|
||||
@@ -27,31 +27,14 @@ 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;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
showTraceDetailsHeaderOptions?: boolean;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
@@ -73,16 +56,15 @@ function DetailsLoader(): JSX.Element {
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
showTraceDetailsHeaderOptions,
|
||||
}: 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);
|
||||
|
||||
@@ -116,8 +98,11 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
@@ -142,7 +127,7 @@ function TraceDetailsHeader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -171,9 +156,9 @@ function TraceDetailsHeader({
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
totalSpansCount={traceSummary.totalSpansCount}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -183,9 +168,9 @@ function TraceDetailsHeader({
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
traceID={traceID || ''}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
@@ -198,18 +183,18 @@ function TraceDetailsHeader({
|
||||
|
||||
{showTraceDetails && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceMetadata ? (
|
||||
{traceSummary ? (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceMetadata.rootServiceName,
|
||||
entryPoint: traceMetadata.rootServiceEntryPoint,
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceMetadata.rootSpanStatusCode}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
@@ -217,7 +202,7 @@ function TraceDetailsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -5,6 +5,11 @@ 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();
|
||||
@@ -51,13 +56,19 @@ jest.mock('components/FieldsSelector', () => ({
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
filterMetadata: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
traceId: 'trace-123',
|
||||
},
|
||||
onFilteredSpansChange: jest.fn(),
|
||||
isDataLoaded: false,
|
||||
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,
|
||||
};
|
||||
|
||||
describe('TraceDetailsHeader – back button', () => {
|
||||
@@ -92,10 +103,32 @@ 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} isDataLoaded={false} />);
|
||||
render(
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
@@ -106,7 +139,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('renders Analytics and Settings action buttons once data is loaded', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
@@ -117,7 +150,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
const panel = screen.getByTestId('analytics-panel');
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
@@ -133,7 +166,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
// Plain prop, no API mock needed: traceMetadata is passed straight in.
|
||||
// useTraceSummary is mocked, so no API call is made.
|
||||
const traceMetadata = {
|
||||
startTimestampMillis: 1_700_000_000_000,
|
||||
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
|
||||
@@ -142,16 +175,20 @@ 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', () => {
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
|
||||
@@ -166,13 +203,8 @@ 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 });
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
// Visible by default (showTraceDetails defaults to true).
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
@@ -192,9 +224,12 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the metadata row when traceMetadata is absent', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
it('shows skeletons instead of the metadata when the summary is absent', () => {
|
||||
const { container } = render(
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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 };
|
||||
}
|
||||
@@ -28,7 +28,6 @@ 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';
|
||||
@@ -323,38 +322,6 @@ 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);
|
||||
@@ -393,10 +360,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
|
||||
traceMetadata={traceMetadataForHeader}
|
||||
showTraceDetailsHeaderOptions={
|
||||
!!traceData?.payload?.spans?.length && !showNoData
|
||||
}
|
||||
/>
|
||||
|
||||
{showNoData ? (
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
traceDetailFieldKeys,
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -149,6 +150,13 @@ 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)),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type {
|
||||
GetFlamegraph200,
|
||||
GetTraceAggregations200,
|
||||
GetTraceSummary200,
|
||||
GetWaterfallV4200,
|
||||
SpantypesFlamegraphSpanDTO,
|
||||
SpantypesSpanAggregationDTO,
|
||||
@@ -303,6 +304,27 @@ 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,
|
||||
|
||||
@@ -918,10 +918,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
sb.OrderBy("__result_0 DESC")
|
||||
}
|
||||
|
||||
if b.operator.Limit > 0 {
|
||||
sb.Limit(b.operator.Limit)
|
||||
}
|
||||
|
||||
combinedArgs := allAggChArgs
|
||||
|
||||
// Add HAVING clause if specified
|
||||
|
||||
@@ -343,8 +343,8 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `service.name`, avg(toFloat64(duration_nano)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400), 10},
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `service.name`, avg(toFloat64(duration_nano)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user