mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 11:10:42 +01:00
Compare commits
29 Commits
feat/trace
...
feat/saved
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b257b627 | ||
|
|
bf5724b6fe | ||
|
|
39effefcec | ||
|
|
99e35e9102 | ||
|
|
f54bc7017a | ||
|
|
6e18ec8d96 | ||
|
|
e42c26d1c0 | ||
|
|
93171ce0a3 | ||
|
|
39f47dc246 | ||
|
|
e288a79ccc | ||
|
|
0b9dbe9654 | ||
|
|
ef15cbb780 | ||
|
|
b2874889f3 | ||
|
|
1df3ca4c55 | ||
|
|
5044073fec | ||
|
|
1123933900 | ||
|
|
a2df96be16 | ||
|
|
a0748b0e95 | ||
|
|
772feac91f | ||
|
|
e293582aea | ||
|
|
9049d56964 | ||
|
|
118e22d35b | ||
|
|
1fa6d58a52 | ||
|
|
dbdb1751e3 | ||
|
|
b630963976 | ||
|
|
c301a03f77 | ||
|
|
04db70bade | ||
|
|
54aaad2cc3 | ||
|
|
c407456be7 |
@@ -104,6 +104,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ importers:
|
||||
'@grafana/data':
|
||||
specifier: ^11.6.15
|
||||
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -30,6 +29,7 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
@@ -729,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<TruncatedTooltip
|
||||
key={option.value || `option-${index}`}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
title={String(option.label || '')}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<div className="option-content">
|
||||
<Typography.Text truncate={1} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</Typography.Text>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -25,6 +26,7 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
const optionsWithHighlight = useMemo(
|
||||
() =>
|
||||
options
|
||||
?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
?.map((option) => ({
|
||||
...option,
|
||||
label: highlightMatchedText(String(option.label || ''), searchText),
|
||||
})),
|
||||
[options, searchText, highlightMatchedText],
|
||||
);
|
||||
const optionsWithHighlight = useMemo(() => {
|
||||
const matching =
|
||||
options?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
// antd shows a value with no matching option as a bare string, so keep the
|
||||
// selected value listed to render it through the tooltip label below.
|
||||
return (
|
||||
isEmpty(value)
|
||||
? matching
|
||||
: prioritizeOrAddOptionForSingleSelect(matching, value)
|
||||
).map((option) => ({
|
||||
...option,
|
||||
// Rendered as the selected value in the closed control.
|
||||
label: (
|
||||
<TruncatedTooltip title={String(option.label || '')} side="bottom">
|
||||
{(textRef): JSX.Element => (
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
),
|
||||
}));
|
||||
}, [options, searchText, value, highlightMatchedText]);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
// Self-provided so label tooltips work wherever this select is rendered.
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactElement, RefObject, useRef, useState } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './TruncatedTooltip.module.scss';
|
||||
|
||||
interface TruncatedTooltipProps {
|
||||
title: string;
|
||||
side?: TooltipSimpleProps['side'];
|
||||
/** Renders the trigger; attach the ref to the element that may truncate. */
|
||||
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
|
||||
}
|
||||
|
||||
function TruncatedTooltip({
|
||||
title,
|
||||
side = 'right',
|
||||
children,
|
||||
}: TruncatedTooltipProps): JSX.Element {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
const el = textRef.current;
|
||||
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={title}
|
||||
side={side}
|
||||
arrow
|
||||
delayDuration={300}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
{children(textRef)}
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default TruncatedTooltip;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
describe('CustomSelect selected value tooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['listed in the options', [{ label: LABEL, value: LABEL }]],
|
||||
['missing from the options', [{ label: 'other', value: 'other' }]],
|
||||
])('reveals a truncated selected value %s on hover', async (_, options) => {
|
||||
render(<CustomSelect options={options} value={LABEL} />);
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(screen.getByText(LABEL));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import TruncatedTooltip from '../TruncatedTooltip';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
function renderRow(scrollWidth: number, clientWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockReturnValue(scrollWidth);
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(clientWidth);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<TruncatedTooltip title={LABEL}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div data-testid="row">
|
||||
<span ref={textRef}>{LABEL}</span>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(element);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TruncatedTooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reveals truncated text when its row is hovered', async () => {
|
||||
renderRow(400, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
|
||||
it('shows no tooltip when the text fits', async () => {
|
||||
renderRow(200, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,51 @@ const LONG_LABEL_OPTION = {
|
||||
value: 'checkout-payment-authorisation',
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
|
||||
export const TruncatedOption: Story = {
|
||||
args: { options: [LONG_LABEL_OPTION, ...options] },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off selected value reveals it in full. */
|
||||
export const TruncatedValue: Story = {
|
||||
args: {
|
||||
options: [LONG_LABEL_OPTION, ...options],
|
||||
value: LONG_LABEL_OPTION.value,
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: a multi-select option reveals its full label the same way. */
|
||||
export const MultiTruncatedOption: Story = {
|
||||
render: (): JSX.Element => (
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
options={[LONG_LABEL_OPTION, ...options]}
|
||||
/>
|
||||
),
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Services' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the select renders, held open: the selected chip revealing the
|
||||
* option label it was cut from. Nothing bounds that label, so the chip is given
|
||||
|
||||
@@ -29,6 +29,13 @@ $custom-border-color: #2c3044;
|
||||
background-color: var(--l2-background);
|
||||
color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
// The search input overlays the selected value; lift the value above it while
|
||||
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
|
||||
&:not(.ant-select-open) .ant-select-selection-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chip styles ONLY in the multi-select
|
||||
@@ -223,6 +230,33 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// Ellipsized option label; also the selected value in the closed single select.
|
||||
.option-label-text {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
|
||||
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) 10.5px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.left-actions {
|
||||
@@ -46,6 +46,9 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
.text {
|
||||
padding-left: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -78,6 +81,8 @@
|
||||
}
|
||||
|
||||
.select-box {
|
||||
--combobox-trigger-width: auto;
|
||||
|
||||
display: flex;
|
||||
padding: 2px 5px;
|
||||
flex-direction: row;
|
||||
@@ -95,10 +100,9 @@
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
--button-variant-link-color: var(--l1-foreground);
|
||||
|
||||
.divider-filter {
|
||||
width: 1px;
|
||||
@@ -106,26 +110,8 @@
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-action-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.settings-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
.active {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Filter,
|
||||
Frown,
|
||||
RefreshCw,
|
||||
Settings2 as SettingsIcon,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -180,7 +180,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
// Helpers to reduce cognitive complexity in main render
|
||||
const renderLeftActions = (): JSX.Element => (
|
||||
<section className="left-actions">
|
||||
<Filter size="md" />
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
@@ -217,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
</Combobox>
|
||||
) : (
|
||||
displayedQueryName && (
|
||||
<Tooltip
|
||||
<TooltipSimple
|
||||
title={`Filter currently in sync with query ${displayedQueryName}`}
|
||||
>
|
||||
<Typography.Text className="sync-tag">
|
||||
{displayedQueryName}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
@@ -231,51 +230,54 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderRightActions = (): JSX.Element => (
|
||||
<section className="right-actions">
|
||||
<Tooltip title="Reset All">
|
||||
<TooltipSimple title="Reset All">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Reset All"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleReset}
|
||||
prefix={<RefreshCw className="sync-icon" size="md" />}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{showFilterCollapse && (
|
||||
<Tooltip title="Collapse Filters">
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && handleFilterVisibilityChange && (
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Collapse Filters"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDynamicFilters && (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
aria-label="Settings"
|
||||
className={classNames('right-action-icon-container', {
|
||||
active: isSettingsOpen,
|
||||
})}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={
|
||||
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
|
||||
<SettingsIcon
|
||||
className="settings-icon"
|
||||
data-testid="settings-icon"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isDynamicFilters &&
|
||||
(isSettingsDisabled ? (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title="Settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
className={classNames({ active: isSettingsOpen })}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
data-testid="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { ComponentProps, ReactNode, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
type QuickFiltersElementProps = Omit<
|
||||
JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>,
|
||||
'handleFilterVisibilityChange'
|
||||
>;
|
||||
|
||||
// What the page configures; the layout adds the open / close wiring.
|
||||
@@ -25,6 +31,7 @@ export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps?: QuickFiltersElementProps;
|
||||
savedViewProps?: SavedViewsElementProps;
|
||||
showFilters: boolean;
|
||||
onToggleFilters?: () => void;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
@@ -35,12 +42,13 @@ function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
savedViewProps,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsOpen, setIsViewsOpen] = useState(false);
|
||||
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
@@ -48,45 +56,55 @@ function QuickFiltersLayout({
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isPanelPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsOpen;
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{hasSavedViews && <SavedViewsRestore source={savedViewProps.source} />}
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
handleFilterVisibilityChange={onToggleFilters}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
|
||||
@@ -23,25 +23,41 @@ jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="saved-views-restore" data-source={source} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
source,
|
||||
savedViewsHeader,
|
||||
handleFilterVisibilityChange,
|
||||
}: {
|
||||
source: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source}
|
||||
{handleFilterVisibilityChange && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse Filters"
|
||||
data-testid="quick-filters-collapse"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
@@ -87,6 +103,10 @@ describe('QuickFiltersLayout', () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-restore')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
@@ -149,6 +169,7 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
@@ -176,12 +197,14 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
@@ -219,8 +242,54 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-panel-header'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
|
||||
it('collapses from the quick filters header when they are shown', async () => {
|
||||
const onToggleFilters = jest.fn();
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
onToggleFilters={onToggleFilters}
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('saved-views-collapse')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('quick-filters-collapse'));
|
||||
expect(onToggleFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('collapses from the saved views header without quick filters', async () => {
|
||||
const onToggleFilters = jest.fn();
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
onToggleFilters={onToggleFilters}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-collapse'));
|
||||
expect(onToggleFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has no collapse without a toggle', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('saved-views-collapse')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;
|
||||
@@ -27,18 +27,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.qf-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qf-header-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +52,17 @@
|
||||
}
|
||||
|
||||
.search {
|
||||
.ant-input {
|
||||
background-color: var(--l1-background);
|
||||
height: 46px;
|
||||
border-radius: 0;
|
||||
}
|
||||
--input-height: var(--spacing-20);
|
||||
--input-border-radius: 0;
|
||||
--input-border-color: transparent;
|
||||
--input-hover-border-color: transparent;
|
||||
--input-focus-border-color: transparent;
|
||||
--input-focus-outline-width: 0;
|
||||
--input-background: var(--l1-background);
|
||||
--input-hover-background: var(--l1-background);
|
||||
--input-focus-background: var(--l1-background);
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.qf-other-filters-list {
|
||||
@@ -125,6 +128,7 @@
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
@@ -43,14 +44,17 @@ function QuickFiltersSettings({
|
||||
<>
|
||||
<div className="qf-header">
|
||||
<div className="qf-title">
|
||||
<TableColumnsSplit width={16} height={16} />
|
||||
<TableColumnsSplit size={16} />
|
||||
Edit quick filters
|
||||
</div>
|
||||
<X
|
||||
className="qf-header-icon"
|
||||
width={16}
|
||||
height={16}
|
||||
<SignozButton
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
onClick={handleSettingsClose}
|
||||
data-testid="quick-filters-settings-close"
|
||||
/>
|
||||
</div>
|
||||
<section className="search">
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface QuickFilterChangeEventData {
|
||||
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange: () => void;
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -138,16 +137,6 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_PIPELINES),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogsViews],
|
||||
keywords: 'logs views',
|
||||
section: 'Logs',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_SAVE_VIEWS),
|
||||
},
|
||||
|
||||
// metrics
|
||||
{
|
||||
@@ -170,16 +159,6 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'metrics-views',
|
||||
name: 'Go to Metrics Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsViews],
|
||||
keywords: 'metrics views',
|
||||
section: 'Metrics',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_VIEWS),
|
||||
},
|
||||
|
||||
// Traces
|
||||
{
|
||||
|
||||
@@ -10,17 +10,14 @@ export const GlobalShortcuts = {
|
||||
// logs
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
|
||||
// traces
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
|
||||
// metrics
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
|
||||
// settings
|
||||
NavigateToSettings: 'shift+g',
|
||||
@@ -42,10 +39,8 @@ export const GlobalShortcutsName = {
|
||||
ToggleSidebar: 'shift+b',
|
||||
NavigateToHome: 'shift+h',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
@@ -55,7 +50,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsDescription = {
|
||||
@@ -69,17 +63,14 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
NavigateToTracesFunnel: 'Navigate to Traces Funnel',
|
||||
NavigateToTracesViews: 'Navigate to Traces Views',
|
||||
NavigateToMetricsSummary: 'Navigate to Metrics Summary',
|
||||
NavigateToMetricsExplorer: 'Navigate to Metrics Explorer',
|
||||
NavigateToMetricsViews: 'Navigate to Metrics Views',
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToLogsViews: 'Navigate to Logs Views',
|
||||
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
|
||||
NavigateToSettingsRoles: 'Navigate to Roles Settings',
|
||||
NavigateToSettingsMembers: 'Navigate to Members Settings',
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { SavedViewSourcePage } from 'container/SavedViews/types';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -26,9 +26,7 @@ function Explorer(): JSX.Element {
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function SavedViews({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<
|
||||
SavedviewtypesSavedViewDTO[]
|
||||
@@ -351,7 +353,7 @@ export default function SavedViews({
|
||||
{selectedEntityViews.length > 0 ? renderSavedViews() : emptyStateCard()}
|
||||
</Card.Content>
|
||||
|
||||
{selectedEntityViews.length > 0 && (
|
||||
{!isSavedViewEnabled && selectedEntityViews.length > 0 && (
|
||||
<Card.Footer>
|
||||
<div className="services-footer home-data-card-footer">
|
||||
<Link to={footerLink}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
InframonitoringtypesHostRecordDTO,
|
||||
InframonitoringtypesHostStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
|
||||
// A block span leaves the icon on the text baseline, a couple of pixels
|
||||
// above the centre of the row it sits in
|
||||
[data-slot='icon'] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
|
||||
78
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.stories.mocks.tsx
generated
Normal file
78
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
export const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
|
||||
const POD_NAME = 'app-demo-loadgenerator-579fbdd749-q5kgd';
|
||||
export const NODE_NAME = 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7';
|
||||
|
||||
const POD_META = {
|
||||
'k8s.pod.uid': POD_UID,
|
||||
'k8s.pod.name': POD_NAME,
|
||||
'k8s.namespace.name': 'generator',
|
||||
'k8s.cluster.name': 'mgmt',
|
||||
'k8s.node.name': NODE_NAME,
|
||||
'k8s.deployment.name': 'app-demo-loadgenerator',
|
||||
};
|
||||
|
||||
const podRecord = {
|
||||
podUID: POD_UID,
|
||||
podCPU: 0.92,
|
||||
podCPURequest: 31,
|
||||
podCPULimit: 30.7,
|
||||
podMemory: 148 * 1024 * 1024,
|
||||
podMemoryRequest: 42,
|
||||
podMemoryLimit: 38,
|
||||
podStatus: 'running',
|
||||
podRestarts: 0,
|
||||
podAge: 49_000_000,
|
||||
meta: POD_META,
|
||||
};
|
||||
|
||||
const container = {
|
||||
containerName: 'loadgenerator',
|
||||
podName: POD_NAME,
|
||||
status: 'running',
|
||||
ready: true,
|
||||
restarts: 0,
|
||||
meta: POD_META,
|
||||
};
|
||||
|
||||
const list = (records: unknown[]): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: { type: 'list', records, total: records.length },
|
||||
});
|
||||
|
||||
export const drawerMocks = defineStoryMocks({
|
||||
controls: {
|
||||
openedFromANode: toggleControl('Opened from a node', {
|
||||
group: 'Drawer',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: () => [
|
||||
rest.post('http://localhost/api/v2/infra_monitoring/pods', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(list([podRecord]))),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/infra_monitoring/kube_containers',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(list([container]))),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { podEntityConfig } from '../Pods/entity.config';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import K8sBaseDetails from './K8sBaseDetails';
|
||||
import {
|
||||
drawerMocks,
|
||||
NODE_NAME,
|
||||
POD_UID,
|
||||
} from './K8sBaseDetails.stories.mocks';
|
||||
import type { K8sBaseDetailsProps } from './types';
|
||||
import {
|
||||
getDrawerHistoryDepth,
|
||||
pushDrawerHistory,
|
||||
resetDrawerHistory,
|
||||
} from './useDrawerHistoryStore';
|
||||
|
||||
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
|
||||
K8sBaseDetailsProps<unknown>;
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
const meta = {
|
||||
title: 'Infra monitoring/Kubernetes drawer',
|
||||
component: K8sBaseDetails as (props: DrawerArgs) => JSX.Element,
|
||||
...storyMocks(drawerMocks, { route }),
|
||||
decorators: [
|
||||
(Story, context): JSX.Element => {
|
||||
// The back control appears only for a resource opened from another
|
||||
const depth = getDrawerHistoryDepth();
|
||||
if (context.args.openedFromANode && depth === 0) {
|
||||
pushDrawerHistory({
|
||||
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: NODE_NAME,
|
||||
entity: { nodeName: NODE_NAME },
|
||||
queryKeyPrefix: 'node',
|
||||
});
|
||||
}
|
||||
if (!context.args.openedFromANode && depth > 0) {
|
||||
resetDrawerHistory();
|
||||
}
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
],
|
||||
} satisfies Meta<DrawerArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DrawerArgs>;
|
||||
|
||||
/** A pod opened from a node's overview tab, so the header leads with the way back. */
|
||||
export const Default: Story = {
|
||||
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { ArrowLeft, X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
@@ -16,12 +16,19 @@ import {
|
||||
useGlobalTimeStore,
|
||||
} from 'store/globalTime';
|
||||
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
K8S_CATEGORY_SINGULAR_LABELS,
|
||||
} from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
import { CategoryIcon } from './categoryIcons';
|
||||
import { getEntityNameAttributeKey } from './relations';
|
||||
import { useDrawerHistoryStore } from './useDrawerHistoryStore';
|
||||
import { usePrimeEntityDetails } from './usePrimeEntityDetails';
|
||||
import { K8sBaseDetailsProps } from './types';
|
||||
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
|
||||
|
||||
@@ -86,6 +93,11 @@ export default function K8sBaseDetails<T>({
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
|
||||
const selectedItemExpression = useMemo(
|
||||
() => getSelectedItemExpression(selectedItemParams),
|
||||
[getSelectedItemExpression, selectedItemParams],
|
||||
);
|
||||
|
||||
const entityQueryKey = useMemo(
|
||||
() =>
|
||||
getAutoRefreshQueryKey(
|
||||
@@ -121,9 +133,10 @@ export default function K8sBaseDetails<T>({
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
|
||||
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
|
||||
const expression = getSelectedItemExpression(selectedItemParams);
|
||||
|
||||
return fetchEntityData({ filter: { expression }, start, end }, signal);
|
||||
return fetchEntityData(
|
||||
{ filter: { expression: selectedItemExpression }, start, end },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
|
||||
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
|
||||
@@ -158,9 +171,29 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
|
||||
|
||||
const previousEntry = useDrawerHistoryStore(
|
||||
(store) => store.entries[store.entries.length - 1] ?? null,
|
||||
);
|
||||
const popDrawerHistory = useDrawerHistoryStore((store) => store.pop);
|
||||
const primeDetails = usePrimeEntityDetails();
|
||||
const resetHistory = useDrawerHistoryStore((store) => store.reset);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
resetHistory();
|
||||
setSelectedItemParams(null);
|
||||
}, [setSelectedItemParams]);
|
||||
}, [resetHistory, setSelectedItemParams]);
|
||||
|
||||
// Steps back through resources opened from an overview tab
|
||||
const handleBack = useCallback((): void => {
|
||||
const previous = popDrawerHistory();
|
||||
|
||||
if (!previous) {
|
||||
return;
|
||||
}
|
||||
|
||||
primeDetails(previous);
|
||||
setSelectedItemParams(previous.params);
|
||||
}, [popDrawerHistory, primeDetails, setSelectedItemParams]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean): void => {
|
||||
@@ -172,11 +205,25 @@ export default function K8sBaseDetails<T>({
|
||||
);
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}, []);
|
||||
toast.success(
|
||||
`${K8S_CATEGORY_SINGULAR_LABELS[category]} name copied to clipboard`,
|
||||
{ position: 'bottom-left' },
|
||||
);
|
||||
}, [category]);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
// meta carries the resource's parents; its own name comes from the config
|
||||
const entityAttributes = useMemo((): Record<string, string> => {
|
||||
const meta = (entity as { meta?: Record<string, string> | null } | null)
|
||||
?.meta;
|
||||
|
||||
return {
|
||||
...(meta ?? {}),
|
||||
...(entityName ? { [getEntityNameAttributeKey(category)]: entityName } : {}),
|
||||
};
|
||||
}, [entity, entityName, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
void logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
@@ -187,20 +234,48 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [entity, eventCategory]);
|
||||
|
||||
// Names the resource it returns to, so a drill several levels deep stays clear
|
||||
const backTarget = previousEntry
|
||||
? `Back to ${K8S_CATEGORY_SINGULAR_LABELS[previousEntry.category]}`
|
||||
: '';
|
||||
const backLabel = previousEntry ? `${backTarget}: ${previousEntry.label}` : '';
|
||||
|
||||
// TODO(H4ad): Improve this on component level
|
||||
// DrawerWrapper types `title` as string but renders any ReactNode.
|
||||
const drawerTitle = (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="close-drawer-button"
|
||||
className={styles.closeButton}
|
||||
prefix={<X />}
|
||||
/>
|
||||
{/* One control: step back where there is a trail, close otherwise.
|
||||
Clicking outside the drawer closes it either way. The label is text
|
||||
rather than a tooltip, which a click on the control would dismiss. */}
|
||||
{previousEntry ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleBack}
|
||||
data-testid="drawer-back-button"
|
||||
className={styles.backButton}
|
||||
aria-label={backLabel}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
>
|
||||
{backTarget}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="close-drawer-button"
|
||||
className={styles.closeButton}
|
||||
prefix={<X />}
|
||||
/>
|
||||
)}
|
||||
<Divider type="vertical" />
|
||||
<span className={styles.entityKind} data-testid="drawer-entity-kind">
|
||||
<CategoryIcon category={category} />
|
||||
{K8S_CATEGORY_SINGULAR_LABELS[category]}
|
||||
</span>
|
||||
<Typography.Text className={styles.title}>
|
||||
{entityName ||
|
||||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
|
||||
@@ -208,8 +283,8 @@ export default function K8sBaseDetails<T>({
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
<CopyButton
|
||||
value={selectedItem ?? ''}
|
||||
ariaLabel="Copy ID"
|
||||
value={entityName || selectedItem || ''}
|
||||
ariaLabel={`Copy ${K8S_CATEGORY_SINGULAR_LABELS[category]} name`}
|
||||
className={styles.copyIdButton}
|
||||
testId="copy-id-button"
|
||||
onCopy={handleCopyId}
|
||||
@@ -271,6 +346,8 @@ export default function K8sBaseDetails<T>({
|
||||
customTabs={customTabs}
|
||||
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
|
||||
eventsInitialExpression={eventsInitialExpression}
|
||||
entityAttributes={entityAttributes}
|
||||
entityName={entityName}
|
||||
/>
|
||||
</GlobalTimeProvider>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
LayoutGrid,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
@@ -31,6 +32,7 @@ import EntityEvents from '../EntityDetailsUtils/EntityEvents';
|
||||
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
|
||||
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityOverview from '../EntityDetailsUtils/EntityOverview/EntityOverview';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
@@ -67,12 +69,16 @@ export default function K8sBaseDetailsContent<T>({
|
||||
customTabs,
|
||||
logsAndTracesInitialExpression,
|
||||
eventsInitialExpression,
|
||||
entityAttributes,
|
||||
entityName,
|
||||
}: K8sBaseDetailsContentProps<T>): JSX.Element {
|
||||
const { timeRange, selectedInterval, handleTimeChange } =
|
||||
useEntityDetailsTime();
|
||||
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
// Declared per category, since only a kubernetes resource relates to others
|
||||
showOverview: false,
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
@@ -86,6 +92,9 @@ export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showOverview) {
|
||||
tabs.push(VIEW_TYPES.OVERVIEW);
|
||||
}
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
@@ -104,14 +113,28 @@ export default function K8sBaseDetailsContent<T>({
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
const defaultView = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hideDetailViewTabs &&
|
||||
selectedView &&
|
||||
!validTabs.includes(selectedView)
|
||||
) {
|
||||
void setSelectedView(defaultView);
|
||||
}
|
||||
}, [
|
||||
hideDetailViewTabs,
|
||||
selectedView,
|
||||
validTabs,
|
||||
defaultView,
|
||||
setSelectedView,
|
||||
]);
|
||||
|
||||
// An absent view param lands on the first tab the category offers
|
||||
const effectiveView = hideDetailViewTabs
|
||||
? VIEW_TYPES.METRICS
|
||||
: (selectedView ?? defaultView);
|
||||
|
||||
// Wait until the view is a valid tab so we don't log a pending value before
|
||||
// the validation effect above corrects an out-of-scope query param
|
||||
@@ -169,20 +192,20 @@ export default function K8sBaseDetailsContent<T>({
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
view: effectiveView,
|
||||
});
|
||||
|
||||
logInfraExplorerNavigatedEvent({
|
||||
entityType: category,
|
||||
destination:
|
||||
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
|
||||
effectiveView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
|
||||
source: 'tab_cta_button',
|
||||
tab: selectedView,
|
||||
tab: effectiveView,
|
||||
sourceKey: null,
|
||||
drawerDurationMsAtNavigation: getDrawerDurationMs(),
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
if (effectiveView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
@@ -207,7 +230,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
} else if (effectiveView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
@@ -264,7 +287,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
entityType={category}
|
||||
activeTab={selectedView}
|
||||
activeTab={effectiveView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,8 +298,21 @@ export default function K8sBaseDetailsContent<T>({
|
||||
type="single"
|
||||
className={styles.viewsTabs}
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
value={effectiveView}
|
||||
items={[
|
||||
...(tabVisibility.showOverview
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className={styles.viewTitle}>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
@@ -341,7 +377,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -354,7 +390,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -370,6 +406,16 @@ export default function K8sBaseDetailsContent<T>({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
|
||||
<EntityOverview
|
||||
category={category}
|
||||
eventEntity={eventCategory}
|
||||
attributes={entityAttributes}
|
||||
entity={entity}
|
||||
entityName={entityName}
|
||||
queryKeyPrefix={queryKeyPrefix}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
@@ -406,7 +452,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
effectiveView === tab.key ? (
|
||||
<Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
|
||||
@@ -39,6 +39,7 @@ import K8sOptionsSidePanel from './K8sOptionsSidePanel';
|
||||
import K8sHeader from './K8sHeader';
|
||||
import { K8sPaginationWarning } from './K8sPaginationWarning';
|
||||
import K8sTableToolbar from './K8sTableToolbar';
|
||||
import { resetDrawerHistory } from './useDrawerHistoryStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
|
||||
@@ -301,6 +302,8 @@ export function K8sBaseList<
|
||||
queryClient.setQueryData(detailQueryKey, { data: record });
|
||||
}
|
||||
|
||||
// Opening from the list starts a fresh trail for the drawer's back control
|
||||
resetDrawerHistory();
|
||||
setSelectedItemParams(params);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useInfraMonitoringCategory } from '../hooks';
|
||||
import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { getEntityConfig } from './entity.registry';
|
||||
import { K8sBaseList } from './K8sBaseList';
|
||||
import K8sBaseDetails from './K8sBaseDetails';
|
||||
@@ -15,27 +18,35 @@ export function K8sDynamicList({
|
||||
leftFilters,
|
||||
}: K8sDynamicListProps): JSX.Element | null {
|
||||
const [selectedCategory] = useInfraMonitoringCategory();
|
||||
const [selectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
|
||||
const config = useMemo(
|
||||
() => getEntityConfig(selectedCategory),
|
||||
[selectedCategory],
|
||||
);
|
||||
|
||||
// A row opened from the drawer's overview tab belongs to another category
|
||||
const drawerCategory = selectedItemParams.category ?? selectedCategory;
|
||||
const drawerConfig = useMemo(
|
||||
() => getEntityConfig(drawerCategory),
|
||||
[drawerCategory],
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { list, details } = config;
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sBaseList
|
||||
{...list}
|
||||
{...config.list}
|
||||
controlListPrefix={controlListPrefix}
|
||||
leftFilters={leftFilters}
|
||||
/>
|
||||
|
||||
<K8sBaseDetails {...details} />
|
||||
{/* No key: remounting would animate the drawer shut and open again
|
||||
for what is really a change of contents */}
|
||||
{drawerConfig && <K8sBaseDetails {...drawerConfig.details} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { resetDrawerHistory } from './useDrawerHistoryStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
|
||||
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
|
||||
@@ -219,6 +220,8 @@ export function K8sExpandedRow<
|
||||
queryClient.setQueryData(detailQueryKey, { data: row });
|
||||
}
|
||||
|
||||
// Opening from the list starts a fresh trail for the drawer's back control
|
||||
resetDrawerHistory();
|
||||
setSelectedItemParams(params);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
type OnUrlUpdateFunction,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../constants';
|
||||
import K8sBaseDetails from '../K8sBaseDetails';
|
||||
import { useDrawerHistoryStore } from '../useDrawerHistoryStore';
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
type TestEntity = { name: string };
|
||||
|
||||
const mockEntity: TestEntity = { name: 'test-container' };
|
||||
|
||||
const NODE_PARAMS = {
|
||||
selectedItem: 'node-1',
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
};
|
||||
|
||||
/** What the drawer records before opening a resource from the node's overview */
|
||||
const NODE_ENTRY = {
|
||||
params: NODE_PARAMS,
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: 'node-1',
|
||||
entity: { name: 'node-1' },
|
||||
queryKeyPrefix: 'node',
|
||||
};
|
||||
|
||||
function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
|
||||
render(
|
||||
<NuqsTestingAdapter
|
||||
searchParams={{
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'container-1',
|
||||
}}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
category={InfraMonitoringEntity.CONTAINERS}
|
||||
eventCategory={InfraMonitoringEvents.Pod}
|
||||
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
|
||||
fetchEntityData={jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null })}
|
||||
getEntityName={(entity): string => entity.name}
|
||||
getInitialLogTracesExpression={(): string => ''}
|
||||
getInitialEventsExpression={(): string => ''}
|
||||
metadataConfig={[
|
||||
{ label: 'Name', getValue: (entity): string => entity.name },
|
||||
]}
|
||||
entityWidgetInfo={[{ title: 'CPU', yAxisUnit: 'percent' }]}
|
||||
getEntityQueryPayload={jest.fn().mockReturnValue([])}
|
||||
queryKeyPrefix="testContainer"
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('K8sBaseDetails - back navigation', () => {
|
||||
beforeEach(() => {
|
||||
useDrawerHistoryStore.getState().reset();
|
||||
});
|
||||
|
||||
it('closes rather than going back when opened from the list', async () => {
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-container').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('drawer-back-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('close-drawer-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces the close control while there is somewhere to go back to', async () => {
|
||||
useDrawerHistoryStore.getState().push(NODE_ENTRY);
|
||||
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
const backButton = await screen.findByTestId('drawer-back-button');
|
||||
|
||||
// The destination is text rather than a tooltip, which a click would dismiss
|
||||
expect(backButton).toHaveTextContent('Back to Node');
|
||||
expect(backButton).toHaveAccessibleName('Back to Node: node-1');
|
||||
expect(screen.queryByTestId('close-drawer-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the resource this one was opened from', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn();
|
||||
|
||||
useDrawerHistoryStore.getState().push(NODE_ENTRY);
|
||||
|
||||
act(() => {
|
||||
renderDrawer(onUrlUpdate);
|
||||
});
|
||||
|
||||
const backButton = await screen.findByTestId('drawer-back-button');
|
||||
await user.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
|
||||
expect(
|
||||
params.some(
|
||||
(searchParams) =>
|
||||
searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM) ===
|
||||
'node-1' &&
|
||||
searchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY,
|
||||
) === InfraMonitoringEntity.NODES,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The trail is one step shorter, so a second back closes instead
|
||||
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the trail when the drawer is dismissed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
useDrawerHistoryStore.getState().push(NODE_ENTRY);
|
||||
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
await screen.findByTestId('drawer-back-button');
|
||||
// Dismissing from outside the drawer, the way a click on the overlay does
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ type TestEntity = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
cluster: string;
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
|
||||
const mockEntity: TestEntity = {
|
||||
@@ -29,14 +30,22 @@ const mockEntity: TestEntity = {
|
||||
cluster: 'test-cluster',
|
||||
};
|
||||
|
||||
function createBaseProps() {
|
||||
/** The overview tab appears only for a resource whose meta relates it to others */
|
||||
const relatedEntity: TestEntity = {
|
||||
...mockEntity,
|
||||
meta: {
|
||||
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
|
||||
'k8s.namespace.name': 'default',
|
||||
'k8s.cluster.name': 'test-cluster',
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
|
||||
return {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
category,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
fetchEntityData: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null }),
|
||||
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
|
||||
getEntityName: (e: TestEntity): string => e.name,
|
||||
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
@@ -51,7 +60,10 @@ function createBaseProps() {
|
||||
|
||||
interface RenderOptions {
|
||||
view?: string;
|
||||
category?: InfraMonitoringEntity;
|
||||
entity?: TestEntity;
|
||||
tabsConfig?: {
|
||||
showOverview?: boolean;
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
@@ -67,6 +79,9 @@ interface RenderOptions {
|
||||
|
||||
function renderK8sBaseDetails({
|
||||
view = VIEW_TYPES.METRICS,
|
||||
// Volumes have no related resources, so they get no overview tab
|
||||
category = InfraMonitoringEntity.VOLUMES,
|
||||
entity = mockEntity,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
}: RenderOptions = {}) {
|
||||
@@ -78,7 +93,7 @@ function renderK8sBaseDetails({
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
{...createBaseProps()}
|
||||
{...createBaseProps(category, entity)}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
@@ -106,6 +121,25 @@ describe('K8sBaseDetails - Tab Validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should land on the overview tab for a category with related resources', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: 'invalid-tab',
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
entity: relatedEntity,
|
||||
tabsConfig: { showOverview: true },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Overview');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { InfraMonitoringEntity } from '../../constants';
|
||||
import {
|
||||
buildRelatedFilterExpression,
|
||||
carryOverListFilters,
|
||||
getRelatedCategories,
|
||||
} from '../relations';
|
||||
|
||||
/** What /api/v2/infra_monitoring/pods returns as a pod's meta. */
|
||||
const POD_ATTRIBUTES = {
|
||||
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
|
||||
'k8s.pod.name': 'checkout-7d9f',
|
||||
'k8s.namespace.name': 'shop',
|
||||
'k8s.cluster.name': 'prod',
|
||||
'k8s.node.name': 'node-1',
|
||||
'k8s.deployment.name': 'checkout',
|
||||
};
|
||||
|
||||
const NODE_ATTRIBUTES = {
|
||||
'k8s.node.name': 'node-1',
|
||||
'k8s.cluster.name': 'prod',
|
||||
};
|
||||
|
||||
describe('getRelatedCategories', () => {
|
||||
it('offers every other kubernetes category, never itself', () => {
|
||||
expect(
|
||||
getRelatedCategories(InfraMonitoringEntity.PODS, POD_ATTRIBUTES),
|
||||
).toStrictEqual([
|
||||
InfraMonitoringEntity.CONTAINERS,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
InfraMonitoringEntity.DAEMONSETS,
|
||||
InfraMonitoringEntity.JOBS,
|
||||
InfraMonitoringEntity.VOLUMES,
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
InfraMonitoringEntity.CLUSTERS,
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes a category it has no relation to by what they share', () => {
|
||||
// Not this pod's statefulsets, which it has none of: the ones beside it
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop'");
|
||||
});
|
||||
|
||||
it('falls back to the cluster where that is all two categories share', () => {
|
||||
// Deployment metrics carry no node, so this relation is unexpressible
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
NODE_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod'");
|
||||
});
|
||||
|
||||
it('finds nothing for a host, which is not a kubernetes resource', () => {
|
||||
// Host rows carry os.type and the host name, no kubernetes attribute
|
||||
expect(
|
||||
getRelatedCategories(InfraMonitoringEntity.HOSTS, {
|
||||
'host.name': 'ip-10-0-0-1',
|
||||
'os.type': 'linux',
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('lists pods first, which makes them the default for a node', () => {
|
||||
expect(
|
||||
getRelatedCategories(InfraMonitoringEntity.NODES, NODE_ATTRIBUTES)[0],
|
||||
).toBe(InfraMonitoringEntity.PODS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRelatedFilterExpression', () => {
|
||||
it('scopes the pods of a node by the node, inside its cluster', () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.PODS,
|
||||
NODE_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
|
||||
});
|
||||
|
||||
it("scopes a pod's containers by the pod uid, which those rows carry", () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.CONTAINERS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe(
|
||||
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.pod.uid = '63edbad0-398e-48e5-baba-f7ded7c73f1b'",
|
||||
);
|
||||
});
|
||||
|
||||
it("reaches a pod's deployment by name, the only key that endpoint has", () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe(
|
||||
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.deployment.name = 'checkout'",
|
||||
);
|
||||
});
|
||||
|
||||
it('names the parent when the drawer resource belongs to it', () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.NODES,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
|
||||
});
|
||||
|
||||
it('asks a cluster list for the cluster alone', () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.CLUSTERS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('carryOverListFilters', () => {
|
||||
it('keeps the clauses the target endpoint understands', () => {
|
||||
expect(
|
||||
carryOverListFilters(
|
||||
"k8s.namespace.name = 'shop' AND k8s.node.condition_ready = true",
|
||||
InfraMonitoringEntity.PODS,
|
||||
),
|
||||
).toBe("k8s.namespace.name = 'shop'");
|
||||
});
|
||||
|
||||
it('drops a filter written against the source entity only', () => {
|
||||
expect(
|
||||
carryOverListFilters('k8s.node.cpu.usage > 0.5', InfraMonitoringEntity.PODS),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('returns nothing for an empty list filter', () => {
|
||||
expect(carryOverListFilters(' ', InfraMonitoringEntity.PODS)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
FilePenLine,
|
||||
Group,
|
||||
HardDrive,
|
||||
Server,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
|
||||
/**
|
||||
* One icon per category, so the section tabs and the drawer that opens from
|
||||
* them show a resource the same way.
|
||||
*/
|
||||
type IconSize = ComponentProps<typeof Container>['size'];
|
||||
|
||||
const CATEGORY_ICONS: Record<InfraMonitoringEntity, typeof Container> = {
|
||||
[InfraMonitoringEntity.HOSTS]: Server,
|
||||
[InfraMonitoringEntity.PODS]: Container,
|
||||
[InfraMonitoringEntity.CONTAINERS]: Box,
|
||||
[InfraMonitoringEntity.NODES]: Workflow,
|
||||
[InfraMonitoringEntity.NAMESPACES]: FilePenLine,
|
||||
[InfraMonitoringEntity.CLUSTERS]: Boxes,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: Computer,
|
||||
[InfraMonitoringEntity.STATEFULSETS]: ArrowUpDown,
|
||||
[InfraMonitoringEntity.DAEMONSETS]: Group,
|
||||
[InfraMonitoringEntity.JOBS]: Bolt,
|
||||
[InfraMonitoringEntity.VOLUMES]: HardDrive,
|
||||
};
|
||||
|
||||
export function CategoryIcon({
|
||||
category,
|
||||
size = 14,
|
||||
}: {
|
||||
category: InfraMonitoringEntity;
|
||||
size?: IconSize;
|
||||
}): JSX.Element {
|
||||
const Icon = CATEGORY_ICONS[category];
|
||||
|
||||
return <Icon size={size} />;
|
||||
}
|
||||
@@ -3,18 +3,11 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { TextNoData } from '../../../components/TextNoData';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../../constants';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
|
||||
import { buildK8sListNavigationUrl } from '../../utils';
|
||||
import styles from './EntityCountsSection.module.scss';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
@@ -55,70 +48,6 @@ export function EntityCountsSection<T>({
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const compositeQuery = {
|
||||
...defaultQuery,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: filterExpression },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
);
|
||||
const detailStartTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
);
|
||||
const detailEndTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
);
|
||||
|
||||
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
|
||||
const listStartTime = currentSearchParams.get(QueryParams.startTime);
|
||||
const listEndTime = currentSearchParams.get(QueryParams.endTime);
|
||||
|
||||
if (listRelativeTime) {
|
||||
urlParams.set(QueryParams.relativeTime, listRelativeTime);
|
||||
} else if (listStartTime && listEndTime) {
|
||||
urlParams.set(QueryParams.startTime, listStartTime);
|
||||
urlParams.set(QueryParams.endTime, listEndTime);
|
||||
}
|
||||
|
||||
if (detailRelativeTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
detailRelativeTime,
|
||||
);
|
||||
} else if (detailStartTime && detailEndTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
detailStartTime,
|
||||
);
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
detailEndTime,
|
||||
);
|
||||
}
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.countsContainer}>
|
||||
{countsConfig.map((config) => (
|
||||
@@ -147,7 +76,7 @@ export function EntityCountsSection<T>({
|
||||
<TextNoData type="typography" className={styles.countValue} />
|
||||
)}
|
||||
<Link
|
||||
to={buildNavigationUrl(config.targetCategory)}
|
||||
to={buildK8sListNavigationUrl(config.targetCategory, filterExpression)}
|
||||
onClick={(): void => handleCardNavigate(config.label)}
|
||||
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
|
||||
@@ -97,7 +97,11 @@ export function logInfraExplorerNavigatedEvent(params: {
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
source:
|
||||
| 'chart_compass_icon'
|
||||
| 'tab_cta_button'
|
||||
| 'stats_card'
|
||||
| 'overview_cta';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
|
||||
265
frontend/src/container/InfraMonitoringK8sV2/Base/relations.ts
Normal file
265
frontend/src/container/InfraMonitoringK8sV2/Base/relations.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
|
||||
/** The attribute that names one record of a category. */
|
||||
const PRIMARY_KEY: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.CLUSTERS]: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
[InfraMonitoringEntity.NAMESPACES]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
[InfraMonitoringEntity.NODES]: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
[InfraMonitoringEntity.STATEFULSETS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
[InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
[InfraMonitoringEntity.VOLUMES]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
[InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
|
||||
};
|
||||
|
||||
/**
|
||||
* Attributes each list endpoint can be filtered by, broadest first so a built
|
||||
* expression reads cluster to container. Only keys the category's metrics
|
||||
* actually carry belong here.
|
||||
*/
|
||||
const FILTERABLE_KEYS: Record<InfraMonitoringEntity, string[]> = {
|
||||
[InfraMonitoringEntity.CLUSTERS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.NODES]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.NAMESPACES]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.STATEFULSETS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.DAEMONSETS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.JOBS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.VOLUMES]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.PODS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
],
|
||||
[InfraMonitoringEntity.CONTAINERS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.HOSTS]: [],
|
||||
};
|
||||
|
||||
/** Dropdown order: what a resource usually contains, then what contains it. */
|
||||
const DISPLAY_ORDER: InfraMonitoringEntity[] = [
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.CONTAINERS,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
InfraMonitoringEntity.DAEMONSETS,
|
||||
InfraMonitoringEntity.JOBS,
|
||||
InfraMonitoringEntity.VOLUMES,
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
InfraMonitoringEntity.CLUSTERS,
|
||||
];
|
||||
|
||||
export type EntityAttributes = Record<string, string>;
|
||||
|
||||
/** The attribute a category's display name belongs under. */
|
||||
const NAME_KEY: Partial<Record<InfraMonitoringEntity, string>> = {
|
||||
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
};
|
||||
|
||||
export function getEntityNameAttributeKey(
|
||||
category: InfraMonitoringEntity,
|
||||
): string {
|
||||
return NAME_KEY[category] ?? PRIMARY_KEY[category];
|
||||
}
|
||||
|
||||
function matchingKeys(
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): string[] {
|
||||
return FILTERABLE_KEYS[target].filter((key) => !!attributes[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The attribute that ties a target to the drawer's resource: the resource
|
||||
* itself when the target's records belong to it (the containers of this pod),
|
||||
* otherwise the target's own name as the resource knows it (the node this pod
|
||||
* runs on). Null when neither holds, which leaves the target scoped only by
|
||||
* the ancestors the two share.
|
||||
*/
|
||||
function relationKey(
|
||||
source: InfraMonitoringEntity,
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): string | null {
|
||||
if (target === source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = matchingKeys(target, attributes);
|
||||
|
||||
if (keys.includes(PRIMARY_KEY[source])) {
|
||||
return PRIMARY_KEY[source];
|
||||
}
|
||||
|
||||
return keys.includes(PRIMARY_KEY[target]) ? PRIMARY_KEY[target] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category the drawer's resource can scope a list of, which for a
|
||||
* kubernetes resource is all of them: where no closer relation exists the two
|
||||
* still share a cluster. A target is left out only when nothing is shared at
|
||||
* all, so a list is never asked for unscoped.
|
||||
*/
|
||||
export function getRelatedCategories(
|
||||
source: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): InfraMonitoringEntity[] {
|
||||
return DISPLAY_ORDER.filter(
|
||||
(target) =>
|
||||
target !== source &&
|
||||
buildRelatedFilterClauses(source, target, attributes).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster and namespace ride along with the relation itself, the way other
|
||||
* observability tools scope these lists: a pod's deployment is read as that
|
||||
* deployment, in that namespace, in that cluster.
|
||||
*/
|
||||
const SCOPE_KEYS = [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export interface RelatedFilterClause {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope clauses, broadest first. The last carries the relation where one
|
||||
* exists; without one the list is scoped to the shared ancestors, which reads
|
||||
* as "beside this resource" rather than "belonging to it".
|
||||
*/
|
||||
export function buildRelatedFilterClauses(
|
||||
source: InfraMonitoringEntity,
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): RelatedFilterClause[] {
|
||||
const key = relationKey(source, target, attributes);
|
||||
|
||||
const scope = SCOPE_KEYS.filter(
|
||||
(scopeKey) =>
|
||||
scopeKey !== key &&
|
||||
FILTERABLE_KEYS[target].includes(scopeKey) &&
|
||||
!!attributes[scopeKey],
|
||||
);
|
||||
|
||||
return (key ? [...scope, key] : scope).map((clauseKey) => ({
|
||||
key: clauseKey,
|
||||
value: attributes[clauseKey] as string,
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatRelatedFilterClause(clause: RelatedFilterClause): string {
|
||||
return `${clause.key} = ${formatValueForExpression(clause.value)}`;
|
||||
}
|
||||
|
||||
export function buildRelatedFilterExpression(
|
||||
source: InfraMonitoringEntity,
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): string {
|
||||
return buildRelatedFilterClauses(source, target, attributes)
|
||||
.map(formatRelatedFilterClause)
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
export function resolveRelatedCategory(
|
||||
source: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
requested: string | null,
|
||||
): InfraMonitoringEntity | null {
|
||||
const related = getRelatedCategories(source, attributes);
|
||||
const match = related.find((target) => target === requested);
|
||||
|
||||
return match ?? related[0] ?? null;
|
||||
}
|
||||
|
||||
export function carryOverListFilters(
|
||||
expression: string,
|
||||
target: InfraMonitoringEntity,
|
||||
): string {
|
||||
if (!expression.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const supported = new Set(FILTERABLE_KEYS[target]);
|
||||
|
||||
return extractQueryPairs(expression)
|
||||
.filter((pair) => supported.has(pair.key))
|
||||
.map((pair) => {
|
||||
const start = pair.position.negationStart ?? pair.position.keyStart;
|
||||
const lastValue = pair.valuesPosition?.[pair.valuesPosition.length - 1];
|
||||
const end =
|
||||
lastValue?.end ?? pair.position.valueEnd ?? pair.position.operatorEnd;
|
||||
|
||||
return expression.slice(start, end + 1).trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
}
|
||||
@@ -75,6 +75,8 @@ export type GetEntityQueryPayload<T> = (
|
||||
) => GetQueryResultsProps[];
|
||||
|
||||
export interface K8sDetailsTabsConfig {
|
||||
/** Declared by categories whose resources relate to others; see relations.ts */
|
||||
showOverview?: boolean;
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
@@ -141,6 +143,10 @@ export interface K8sBaseDetailsContentProps<T> {
|
||||
customTabs?: K8sDetailsCustomTab<T>[];
|
||||
logsAndTracesInitialExpression: string;
|
||||
eventsInitialExpression: string;
|
||||
/** The drawer entity's own attributes, used to find and scope related resources */
|
||||
entityAttributes: Record<string, string>;
|
||||
/** Name of the drawer entity, for the back control of a resource opened from it */
|
||||
entityName: string;
|
||||
}
|
||||
|
||||
// Aliases for backward compatibility
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
export interface DrawerHistoryEntry {
|
||||
params: SelectedItemParams;
|
||||
/** Category of the resource that was open, which the back control names */
|
||||
category: InfraMonitoringEntity;
|
||||
/** Name of that resource, shown on hover */
|
||||
label: string;
|
||||
/**
|
||||
* The record the drawer was showing, and the prefix of the query that holds
|
||||
* it, so stepping back renders from it instead of fetching again.
|
||||
*/
|
||||
entity: unknown;
|
||||
queryKeyPrefix: string;
|
||||
}
|
||||
|
||||
export interface IDrawerHistoryStore {
|
||||
entries: DrawerHistoryEntry[];
|
||||
push: (entry: DrawerHistoryEntry) => void;
|
||||
pop: () => DrawerHistoryEntry | null;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail left by opening one resource from another's overview tab, so the
|
||||
* drawer can step back through it instead of only closing.
|
||||
*/
|
||||
export const useDrawerHistoryStore = create<IDrawerHistoryStore>()(
|
||||
(set, get) => ({
|
||||
entries: [],
|
||||
push: (entry): void => set({ entries: [...get().entries, entry] }),
|
||||
pop: (): DrawerHistoryEntry | null => {
|
||||
const { entries } = get();
|
||||
const previous = entries[entries.length - 1] ?? null;
|
||||
|
||||
if (previous) {
|
||||
set({ entries: entries.slice(0, -1) });
|
||||
}
|
||||
|
||||
return previous;
|
||||
},
|
||||
reset: (): void => set({ entries: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const resetDrawerHistory = (): void =>
|
||||
useDrawerHistoryStore.getState().reset();
|
||||
|
||||
export const pushDrawerHistory = (entry: DrawerHistoryEntry): void =>
|
||||
useDrawerHistoryStore.getState().push(entry);
|
||||
|
||||
export const getDrawerHistoryDepth = (): number =>
|
||||
useDrawerHistoryStore.getState().entries.length;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
interface PrimeEntityDetailsParams {
|
||||
params: SelectedItemParams;
|
||||
queryKeyPrefix: string;
|
||||
entity: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the drawer's entity query with a record already in hand, so navigating
|
||||
* between resources renders straight away instead of passing through a loading
|
||||
* state. The list page does the same on a row click.
|
||||
*/
|
||||
export function usePrimeEntityDetails(): (
|
||||
params: PrimeEntityDetailsParams,
|
||||
) => void {
|
||||
const queryClient = useQueryClient();
|
||||
const selectedTime = useGlobalTimeStore((store) => store.selectedTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(store) => store.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
({ params, queryKeyPrefix, entity }): void => {
|
||||
if (!queryKeyPrefix || !entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
),
|
||||
{ data: entity },
|
||||
);
|
||||
},
|
||||
[queryClient, selectedTime, getAutoRefreshQueryKey],
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,16 @@ import {
|
||||
formatValueForExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function sortByColumnOrder<T>(
|
||||
items: T[],
|
||||
@@ -178,3 +187,74 @@ export function buildExpressionFromSelectedItemParams(
|
||||
|
||||
return clauses.join(' AND ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to the k8s list page for `targetCategory`, pre-filtered by `filterExpression`
|
||||
* and carrying whichever time range (list or drawer) the current URL holds.
|
||||
*/
|
||||
export function buildK8sListNavigationUrl(
|
||||
targetCategory: InfraMonitoringEntity,
|
||||
filterExpression: string,
|
||||
): string {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const compositeQuery = {
|
||||
...defaultQuery,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: filterExpression },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
);
|
||||
const detailStartTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
);
|
||||
const detailEndTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
);
|
||||
|
||||
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
|
||||
const listStartTime = currentSearchParams.get(QueryParams.startTime);
|
||||
const listEndTime = currentSearchParams.get(QueryParams.endTime);
|
||||
|
||||
if (listRelativeTime) {
|
||||
urlParams.set(QueryParams.relativeTime, listRelativeTime);
|
||||
} else if (listStartTime && listEndTime) {
|
||||
urlParams.set(QueryParams.startTime, listStartTime);
|
||||
urlParams.set(QueryParams.endTime, listEndTime);
|
||||
}
|
||||
|
||||
if (detailRelativeTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
detailRelativeTime,
|
||||
);
|
||||
} else if (detailStartTime && detailEndTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
detailStartTime,
|
||||
);
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
detailEndTime,
|
||||
);
|
||||
}
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -123,6 +123,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CLUSTERS,
|
||||
eventCategory: InfraMonitoringEvents.Cluster,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'cluster',
|
||||
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const containerEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'container',
|
||||
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { Box } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
@@ -94,7 +94,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Container Name"
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
icon={<Box data-hide-expanded="true" size={14} />}
|
||||
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,6 +132,7 @@ 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/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -147,6 +147,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
eventCategory: InfraMonitoringEvents.Deployment,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'deployment',
|
||||
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.resourcePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resourceTrigger {
|
||||
--select-trigger-width: 240px;
|
||||
--select-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.resourceOptions {
|
||||
// The drawer is a dialog at z-index 50 and select content ships without one,
|
||||
// so without this the dropdown paints behind the drawer.
|
||||
z-index: 60;
|
||||
|
||||
// Radix measures the trigger for us; without this the list is narrower than it
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding-left: var(--spacing-3);
|
||||
min-width: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
|
||||
// The row draws the field outline, so the editor inside it draws none
|
||||
--query-builder-v2-border-color: transparent;
|
||||
--query-builder-v2-background-color: transparent;
|
||||
}
|
||||
|
||||
.scopeChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 420px;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scopeMore {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// The editor beside it is CodeMirror on the copilot theme in dark and
|
||||
// githubLight in light; the chip borrows those token colours so one filter
|
||||
// does not read as two
|
||||
.scopeKey {
|
||||
color: #939da5;
|
||||
}
|
||||
|
||||
.scopeOperator {
|
||||
color: #939da5;
|
||||
}
|
||||
|
||||
.scopeValue {
|
||||
color: #5bec95;
|
||||
}
|
||||
|
||||
:global(.lightMode) {
|
||||
.scopeKey,
|
||||
.scopeOperator {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.scopeValue {
|
||||
color: #032f62;
|
||||
}
|
||||
}
|
||||
|
||||
.scopeExpression {
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
// The locked scope is already shown as a chip, so drop the editor's own
|
||||
// scope marker and the room it reserves for it
|
||||
:global(.query-search-initial-scope-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(
|
||||
.query-where-clause-editor.hasInitialExpression .cm-editor .cm-content
|
||||
) {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
// Its page-width minimum would force the filter row to overflow the drawer
|
||||
:global(.code-mirror-where-clause .query-where-clause-editor) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 480px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
--typography-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.noRelations {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
function buildContainers(count: number): unknown[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
containerName: index === 0 ? 'loadgenerator' : `sidecar-${index}`,
|
||||
containerID: `containerd://${index}`,
|
||||
podName: 'app-demo-loadgenerator-579fbdd749-q5kgd',
|
||||
status: 'running',
|
||||
ready: true,
|
||||
restarts: index,
|
||||
image: 'ghcr.io/open-telemetry/demo:1.14.0-loadgenerator',
|
||||
cpuUsage: 0.12,
|
||||
memoryUsage: 148 * 1024 * 1024,
|
||||
meta: {
|
||||
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
|
||||
'k8s.namespace.name': 'generator',
|
||||
'k8s.cluster.name': 'mgmt',
|
||||
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const overviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
containers: countControl('Containers', {
|
||||
group: 'Overview tab',
|
||||
value: 12,
|
||||
max: 40,
|
||||
}),
|
||||
},
|
||||
handlers: (values) => [
|
||||
// The filter's suggestions ask for these; without them the search box
|
||||
// logs a failed request and the story's smoke test fails on it
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/infra_monitoring/kube_containers',
|
||||
async (req, res, ctx) => {
|
||||
const { offset = 0, limit = 10 } = (await req.json()) as {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'list',
|
||||
records: buildContainers(values.containers).slice(
|
||||
offset,
|
||||
offset + limit,
|
||||
),
|
||||
total: values.containers,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { GlobalTimeProvider } from 'store/globalTime';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../constants';
|
||||
import EntityOverview, { EntityOverviewProps } from './EntityOverview';
|
||||
import { overviewMocks } from './EntityOverview.stories.mocks';
|
||||
|
||||
import drawerStyles from '../entityDetails.module.scss';
|
||||
|
||||
const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
|
||||
|
||||
/** The meta /api/v2/infra_monitoring/pods returns for one pod. */
|
||||
const POD_ATTRIBUTES = {
|
||||
'k8s.pod.uid': POD_UID,
|
||||
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
|
||||
'k8s.namespace.name': 'generator',
|
||||
'k8s.cluster.name': 'mgmt',
|
||||
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
|
||||
'k8s.deployment.name': 'app-demo-loadgenerator',
|
||||
};
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
type OverviewArgs = PageStoryArgs<typeof overviewMocks> & EntityOverviewProps;
|
||||
|
||||
const meta = {
|
||||
title: 'Infra monitoring/Drawer overview tab',
|
||||
component: EntityOverview,
|
||||
...storyMocks(overviewMocks, { route }),
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<DrawerWrapper
|
||||
open
|
||||
direction="right"
|
||||
title="app-demo-loadgenerator-579fbdd749-q5kgd"
|
||||
onOpenChange={(): void => undefined}
|
||||
className={drawerStyles.entityDetailDrawer}
|
||||
>
|
||||
<GlobalTimeProvider>
|
||||
<Story />
|
||||
</GlobalTimeProvider>
|
||||
</DrawerWrapper>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<OverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<OverviewArgs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventEntity: 'pod',
|
||||
attributes: POD_ATTRIBUTES,
|
||||
entity: { podUID: POD_UID },
|
||||
entityName: POD_ATTRIBUTES['k8s.pod.name'],
|
||||
queryKeyPrefix: 'pod',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@signozhq/ui/select';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
useUserExpression,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import { getEntityConfig } from '../../Base/entity.registry';
|
||||
import { logInfraExplorerNavigatedEvent } from '../../Base/events';
|
||||
import {
|
||||
buildRelatedFilterClauses,
|
||||
EntityAttributes,
|
||||
formatRelatedFilterClause,
|
||||
RelatedFilterClause,
|
||||
getEntityNameAttributeKey,
|
||||
getRelatedCategories,
|
||||
resolveRelatedCategory,
|
||||
} from '../../Base/relations';
|
||||
import { getDrawerDurationMs } from '../../Base/useDrawerLifecycleStore';
|
||||
import { buildK8sListNavigationUrl } from '../../Base/utils';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
K8S_CATEGORY_LABELS,
|
||||
K8S_CATEGORY_SINGULAR_LABELS,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
VIEW_TYPES,
|
||||
} from '../../constants';
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from '../events';
|
||||
import { RelatedEntitiesTable } from './RelatedEntitiesTable';
|
||||
import { ScopeChip } from './ScopeChip';
|
||||
import { useCarriedOverFilters } from './useCarriedOverFilters';
|
||||
import { useRelatedEntities } from './useRelatedEntities';
|
||||
|
||||
import styles from './EntityOverview.module.scss';
|
||||
|
||||
export interface EntityOverviewProps {
|
||||
category: InfraMonitoringEntity;
|
||||
eventEntity: string;
|
||||
/** The drawer entity's own attributes, which scope every related query */
|
||||
attributes: EntityAttributes;
|
||||
/** The drawer's own record and query prefix, so a row click can leave a
|
||||
* trail that steps back without fetching again */
|
||||
entity: unknown;
|
||||
entityName: string;
|
||||
queryKeyPrefix: string;
|
||||
}
|
||||
|
||||
interface ContentProps extends EntityOverviewProps {
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
lockedExpression: string;
|
||||
lockedClauses: RelatedFilterClause[];
|
||||
onCategoryChange: (category: string) => void;
|
||||
}
|
||||
|
||||
function EntityOverviewContent({
|
||||
category,
|
||||
eventEntity,
|
||||
attributes,
|
||||
entity,
|
||||
entityName,
|
||||
queryKeyPrefix,
|
||||
targetCategory,
|
||||
lockedExpression,
|
||||
lockedClauses,
|
||||
onCategoryChange,
|
||||
}: ContentProps): JSX.Element | null {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const expression = useExpression();
|
||||
const userExpression = useUserExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const querySearchOnChange = useQuerySearchOnChange();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
|
||||
|
||||
const targetConfig = getEntityConfig(targetCategory);
|
||||
|
||||
useCarriedOverFilters(targetCategory);
|
||||
|
||||
const {
|
||||
records,
|
||||
total,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
endTimeBeforeRetention,
|
||||
setPage,
|
||||
setLimit,
|
||||
refetch,
|
||||
cancel,
|
||||
} = useRelatedEntities({
|
||||
fetchListData: targetConfig?.list.fetchListData,
|
||||
queryKey: `${targetCategory}RelatedEntities`,
|
||||
expression,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// The editor holds only what the user typed; the locked scope is shown beside it
|
||||
const queryData = useMemo(
|
||||
(): IBuilderQuery =>
|
||||
({
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: userExpression },
|
||||
}) as IBuilderQuery,
|
||||
[userExpression],
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(value: string | string[]): void => {
|
||||
if (typeof value === 'string') {
|
||||
onCategoryChange(value);
|
||||
}
|
||||
},
|
||||
[onCategoryChange],
|
||||
);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const newUserExpression = updatedExpression
|
||||
? getUserExpressionFromCombined(lockedExpression, updatedExpression)
|
||||
: inputExpression;
|
||||
|
||||
const validation = validateQuery(
|
||||
combineInitialAndUserExpression(lockedExpression, newUserExpression || ''),
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
querySearchOnRun(newUserExpression);
|
||||
logInfraDrawerFilterCustomizedEvent(
|
||||
category,
|
||||
VIEW_TYPES.OVERVIEW,
|
||||
newUserExpression || '',
|
||||
'search',
|
||||
);
|
||||
refetch();
|
||||
},
|
||||
[lockedExpression, inputExpression, querySearchOnRun, category, refetch],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback((): void => {
|
||||
logInfraExplorerNavigatedEvent({
|
||||
entityType: category,
|
||||
destination: 'k8s_list',
|
||||
source: 'overview_cta',
|
||||
tab: VIEW_TYPES.OVERVIEW,
|
||||
sourceKey: targetCategory,
|
||||
drawerDurationMsAtNavigation: getDrawerDurationMs(),
|
||||
});
|
||||
|
||||
openInNewTab(buildK8sListNavigationUrl(targetCategory, expression));
|
||||
}, [category, targetCategory, expression]);
|
||||
|
||||
if (!targetConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetLabel = K8S_CATEGORY_LABELS[targetCategory];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controls}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={VIEW_TYPES.OVERVIEW}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
handleCancelQuery={cancel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<ScopeChip clauses={lockedClauses} expression={lockedExpression} />
|
||||
|
||||
<div className={styles.filterInput}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
dataSource={DataSource.METRICS}
|
||||
onChange={querySearchOnChange}
|
||||
onRun={handleRunQuery}
|
||||
signalSource=""
|
||||
showFilterSuggestionsWithoutMetric
|
||||
placeholder={`Filter these ${targetLabel.toLowerCase()}`}
|
||||
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[targetCategory]}
|
||||
initialExpression={querySearchInitialExpressionProp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RelatedEntitiesTable
|
||||
key={targetCategory}
|
||||
targetCategory={targetCategory}
|
||||
header={
|
||||
<div className={styles.resourcePicker}>
|
||||
<Typography.Text color="muted" size="small">
|
||||
Showing
|
||||
</Typography.Text>
|
||||
|
||||
<Select value={targetCategory} onChange={handleCategoryChange}>
|
||||
<SelectTrigger
|
||||
className={styles.resourceTrigger}
|
||||
data-testid="overview-resource-select"
|
||||
>
|
||||
<SelectValue>{targetLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
{/* The drawer sits at z-index 50 and select content ships without one */}
|
||||
<SelectContent className={styles.resourceOptions}>
|
||||
{getRelatedCategories(category, attributes).map((relatedCategory) => (
|
||||
<SelectItem
|
||||
key={relatedCategory}
|
||||
value={relatedCategory}
|
||||
testId={`overview-resource-option-${relatedCategory}`}
|
||||
>
|
||||
{K8S_CATEGORY_LABELS[relatedCategory]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<TooltipSimple
|
||||
title={`View on ${targetLabel.toLowerCase()} page`}
|
||||
side="top"
|
||||
arrow
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNavigate}
|
||||
data-testid="overview-view-on-list-page"
|
||||
prefix={<Compass size={16} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
}
|
||||
listConfig={targetConfig.list}
|
||||
records={records}
|
||||
total={total}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
endTimeBeforeRetention={endTimeBeforeRetention}
|
||||
source={{ category, label: entityName, entity, queryKeyPrefix }}
|
||||
onPageChange={setPage}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EntityOverview(
|
||||
props: EntityOverviewProps,
|
||||
): JSX.Element | null {
|
||||
const { category, attributes } = props;
|
||||
|
||||
// Tied to the resource it was picked on, so another drawer starts at its
|
||||
// own default instead of inheriting the last pick
|
||||
const [selection, setSelection] = useState<{
|
||||
identity: string;
|
||||
category: string;
|
||||
} | null>(null);
|
||||
|
||||
const identity = `${category}:${
|
||||
attributes[getEntityNameAttributeKey(category)] ?? ''
|
||||
}`;
|
||||
|
||||
const targetCategory = resolveRelatedCategory(
|
||||
category,
|
||||
attributes,
|
||||
selection?.identity === identity ? selection.category : null,
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(value: string): void => setSelection({ identity, category: value }),
|
||||
[identity],
|
||||
);
|
||||
|
||||
const lockedClauses = targetCategory
|
||||
? buildRelatedFilterClauses(category, targetCategory, attributes)
|
||||
: [];
|
||||
const lockedExpression = lockedClauses
|
||||
.map(formatRelatedFilterClause)
|
||||
.join(' AND ');
|
||||
|
||||
if (!targetCategory) {
|
||||
return (
|
||||
<div className={styles.noRelations}>
|
||||
<Typography.Text color="muted">
|
||||
{`Nothing related to this ${K8S_CATEGORY_SINGULAR_LABELS[category]} was reported in this time range.`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION}
|
||||
initialExpression={lockedExpression}
|
||||
>
|
||||
<EntityOverviewContent
|
||||
{...props}
|
||||
targetCategory={targetCategory}
|
||||
lockedExpression={lockedExpression}
|
||||
lockedClauses={lockedClauses}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
K8sEntityData,
|
||||
K8sEntityListConfig,
|
||||
} from '../../Base/entity.config.types';
|
||||
import { K8sEmptyState } from '../../Base/K8sEmptyState';
|
||||
import {
|
||||
DrawerHistoryEntry,
|
||||
useDrawerHistoryStore,
|
||||
} from '../../Base/useDrawerHistoryStore';
|
||||
import {
|
||||
useInfraMonitoringFontSize,
|
||||
useInfraMonitoringLineClamp,
|
||||
} from '../../Base/useInfraMonitoringTablePreferencesStore';
|
||||
import { InfraMonitoringEntity, K8S_CATEGORY_LABELS } from '../../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../../hooks';
|
||||
import { usePrimeEntityDetails } from '../../Base/usePrimeEntityDetails';
|
||||
import { RELATED_ENTITIES_PAGE_SIZE } from './useRelatedEntities';
|
||||
|
||||
import listStyles from '../../Base/K8sBaseList.module.scss';
|
||||
import styles from './EntityOverview.module.scss';
|
||||
|
||||
interface RelatedEntitiesTableProps {
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
listConfig: K8sEntityListConfig<K8sEntityData, string | SelectedItemParams>;
|
||||
records: K8sEntityData[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError | null;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
/** Rendered in the card's header strip, above the rows it describes */
|
||||
header: ReactNode;
|
||||
/** The drawer's own resource, so a row click can leave a trail back to it */
|
||||
source: Omit<DrawerHistoryEntry, 'params'>;
|
||||
}
|
||||
|
||||
export function RelatedEntitiesTable({
|
||||
targetCategory,
|
||||
listConfig,
|
||||
records,
|
||||
total,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
endTimeBeforeRetention,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
header,
|
||||
source,
|
||||
}: RelatedEntitiesTableProps): JSX.Element {
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const pushDrawerHistory = useDrawerHistoryStore((store) => store.push);
|
||||
const primeDetails = usePrimeEntityDetails();
|
||||
const lineClamp = useInfraMonitoringLineClamp();
|
||||
const fontSize = useInfraMonitoringFontSize();
|
||||
|
||||
// Opens the clicked resource in its own drawer, overview tab included
|
||||
const handleRowClick = useCallback(
|
||||
(record: K8sEntityData, itemKey: string | SelectedItemParams): void => {
|
||||
const params: SelectedItemParams =
|
||||
typeof itemKey === 'object'
|
||||
? itemKey
|
||||
: { selectedItem: itemKey, clusterName: null, namespaceName: null };
|
||||
|
||||
pushDrawerHistory({ ...source, params: selectedItemParams });
|
||||
|
||||
// The row already carries the record the drawer is about to fetch
|
||||
primeDetails({
|
||||
params,
|
||||
queryKeyPrefix: listConfig.detailsQueryKeyPrefix,
|
||||
entity: record,
|
||||
});
|
||||
setSelectedItemParams({ ...params, category: targetCategory });
|
||||
},
|
||||
[
|
||||
primeDetails,
|
||||
pushDrawerHistory,
|
||||
selectedItemParams,
|
||||
source,
|
||||
setSelectedItemParams,
|
||||
targetCategory,
|
||||
listConfig.detailsQueryKeyPrefix,
|
||||
],
|
||||
);
|
||||
|
||||
const isEmpty = !isLoading && records.length === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.tableCard}>
|
||||
<div className={styles.tableHeader}>{header}</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.emptyState}>
|
||||
<K8sEmptyState
|
||||
isError={isError}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
endTimeBeforeRetention={endTimeBeforeRetention}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TanStackTable<K8sEntityData, string | SelectedItemParams>
|
||||
data={records}
|
||||
columns={listConfig.tableColumns}
|
||||
columnStorageKey={`k8s-overview-${targetCategory}-columns`}
|
||||
isLoading={isLoading}
|
||||
getRowKey={listConfig.getRowKey}
|
||||
getItemKey={listConfig.getItemKey}
|
||||
onRowClick={handleRowClick}
|
||||
className={cx(listStyles.k8SListTable, styles.table)}
|
||||
testId={`related-entities-table-${targetCategory}`}
|
||||
// A page of rows is short enough to render whole, which keeps the card
|
||||
// at the height of its rows instead of stretching down the drawer
|
||||
disableVirtualScroll
|
||||
pagination={{
|
||||
total,
|
||||
defaultLimit: RELATED_ENTITIES_PAGE_SIZE,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: K8S_CATEGORY_LABELS[targetCategory],
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
plainTextCellLineClamp={lineClamp}
|
||||
cellTypographySize={fontSize}
|
||||
paginationClassname={listStyles.paginationContainer}
|
||||
resetScrollKey={targetCategory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import { RelatedFilterClause } from '../../Base/relations';
|
||||
|
||||
import styles from './EntityOverview.module.scss';
|
||||
|
||||
interface ScopeChipProps {
|
||||
clauses: RelatedFilterClause[];
|
||||
/** The clauses as one expression, shown in full on hover */
|
||||
expression: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The locked half of the filter. It reads like the editor beside it: the last
|
||||
* clause carries the relation, the ones scoping it are counted instead.
|
||||
*/
|
||||
export function ScopeChip({
|
||||
clauses,
|
||||
expression,
|
||||
}: ScopeChipProps): JSX.Element {
|
||||
const relation = clauses[clauses.length - 1];
|
||||
|
||||
return (
|
||||
<TooltipSimple title={expression} side="top" arrow>
|
||||
<span className={styles.scopeChip} data-testid="overview-locked-scope">
|
||||
<Lock size={12} />
|
||||
<span className={styles.scopeExpression}>
|
||||
<span className={styles.scopeKey}>{relation.key}</span>
|
||||
<span className={styles.scopeOperator}> = </span>
|
||||
<span className={styles.scopeValue}>
|
||||
{formatValueForExpression(relation.value)}
|
||||
</span>
|
||||
</span>
|
||||
{clauses.length > 1 && (
|
||||
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
import EntityOverview from '../EntityOverview';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
const mockQuerySearchProps = jest.fn();
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
queryData?: { filter?: { expression?: string } };
|
||||
initialExpression?: string;
|
||||
}): JSX.Element => {
|
||||
mockQuerySearchProps(props);
|
||||
return <div data-testid="mock-query-search" />;
|
||||
},
|
||||
}));
|
||||
|
||||
type TestRecord = { name: string; meta?: Record<string, string> | null };
|
||||
|
||||
const mockFetchListData = jest.fn();
|
||||
|
||||
const mockTableColumns: TableColumnDef<TestRecord>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('../../../Base/entity.registry', () => ({
|
||||
getEntityConfig: (category: string) => ({
|
||||
list: {
|
||||
entity: category,
|
||||
tableColumns: mockTableColumns,
|
||||
fetchListData: mockFetchListData,
|
||||
getRowKey: (record: TestRecord): string => record.name,
|
||||
getItemKey: (record: TestRecord): string => record.name,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const NODE_ATTRIBUTES = {
|
||||
'k8s.node.name': 'node-1',
|
||||
'k8s.cluster.name': 'prod',
|
||||
};
|
||||
|
||||
const LOCKED_EXPRESSION =
|
||||
"k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'";
|
||||
|
||||
interface RenderOptions {
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
attributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
function overviewTree(
|
||||
attributes: Record<string, string>,
|
||||
queryClient: QueryClient,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<EntityOverview
|
||||
category={InfraMonitoringEntity.NODES}
|
||||
eventEntity="node"
|
||||
attributes={attributes}
|
||||
entity={{ nodeName: attributes['k8s.node.name'] }}
|
||||
entityName={attributes['k8s.node.name']}
|
||||
queryKeyPrefix="node"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOverview({
|
||||
onUrlUpdate,
|
||||
attributes = NODE_ATTRIBUTES,
|
||||
}: RenderOptions = {}): {
|
||||
openAnotherResource: (next: Record<string, string>) => void;
|
||||
} {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
overviewTree(attributes, queryClient, onUrlUpdate),
|
||||
);
|
||||
|
||||
return {
|
||||
openAnotherResource: (next): void =>
|
||||
rerender(overviewTree(next, queryClient, onUrlUpdate)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EntityOverview', () => {
|
||||
beforeEach(() => {
|
||||
mockQuerySearchProps.mockReset();
|
||||
mockFetchListData.mockReset();
|
||||
mockFetchListData.mockResolvedValue({
|
||||
type: 'list',
|
||||
records: [{ name: 'pod-1' }],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the first related resource scoped by the locked expression', async () => {
|
||||
renderOverview();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchListData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockFetchListData.mock.calls[0][0].filter.expression).toBe(
|
||||
LOCKED_EXPRESSION,
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-pods'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the locked scope out of the editable filter', async () => {
|
||||
renderOverview();
|
||||
|
||||
// The chip leads with the relation and counts the scope behind it
|
||||
const chip = await screen.findByTestId('overview-locked-scope');
|
||||
expect(chip).toHaveTextContent("k8s.node.name = 'node-1'");
|
||||
expect(chip).toHaveTextContent('+1');
|
||||
|
||||
// The editor is handed the user's own expression, never the locked scope
|
||||
const lastProps = mockQuerySearchProps.mock.calls.at(-1)?.[0];
|
||||
expect(lastProps.queryData.filter.expression).toBe('');
|
||||
expect(lastProps.initialExpression).toBe(LOCKED_EXPRESSION);
|
||||
});
|
||||
|
||||
it('opens the clicked resource in its own drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn();
|
||||
|
||||
renderOverview({ onUrlUpdate });
|
||||
|
||||
const row = await screen.findByText('pod-1');
|
||||
await user.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
|
||||
expect(
|
||||
params.some(
|
||||
(searchParams) =>
|
||||
searchParams.get('selectedItem') === 'pod-1' &&
|
||||
searchParams.get('selectedItemCategory') === 'pods',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("forgets the previous drawer's resource when another one opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { openAnotherResource } = renderOverview();
|
||||
|
||||
await user.click(screen.getByTestId('overview-resource-select'));
|
||||
await user.click(
|
||||
await screen.findByTestId('overview-resource-option-containers'),
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-containers'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
openAnotherResource({ ...NODE_ATTRIBUTES, 'k8s.node.name': 'node-2' });
|
||||
|
||||
// Back to pods, this node's default, rather than the containers just picked
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-pods'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says so when the resource relates to nothing', async () => {
|
||||
renderOverview({ attributes: { 'os.type': 'linux' } });
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Nothing related to this Node/),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(mockFetchListData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches the listed resource from the dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderOverview();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchListData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId('overview-resource-select'));
|
||||
|
||||
// Every resource related to a node is offered, the listed one included
|
||||
await expect(
|
||||
screen.findByTestId('overview-resource-option-pods'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
await screen.findByTestId('overview-resource-option-containers'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-containers'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuerySearchOnRun } from 'components/QueryBuilderV2';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { carryOverListFilters } from '../../Base/relations';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../constants';
|
||||
|
||||
/**
|
||||
* Seeds the editable half of the filter with whatever the list page was
|
||||
* filtered by, reduced to the clauses the listed resource understands. Runs
|
||||
* once per resource, so it never overwrites what the user types, and stands
|
||||
* aside for a drawer opened from a link that already carries a filter.
|
||||
*/
|
||||
export function useCarriedOverFilters(
|
||||
targetCategory: InfraMonitoringEntity,
|
||||
): void {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const listExpression =
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const [urlExpression] = useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const seededFor = useRef<string | null>(null);
|
||||
const hadUrlExpressionOnMount = useRef<boolean | null>(null);
|
||||
if (hadUrlExpressionOnMount.current === null) {
|
||||
hadUrlExpressionOnMount.current = !!urlExpression;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (seededFor.current === targetCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstResource = seededFor.current === null;
|
||||
seededFor.current = targetCategory;
|
||||
|
||||
if (isFirstResource && hadUrlExpressionOnMount.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
querySearchOnRun(carryOverListFilters(listExpression, targetCategory));
|
||||
}, [targetCategory, listExpression, querySearchOnRun]);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
K8sEntityData,
|
||||
K8sEntityListConfig,
|
||||
} from '../../Base/entity.config.types';
|
||||
import { SelectedItemParams } from '../../hooks';
|
||||
|
||||
type FetchRelatedEntities = K8sEntityListConfig<
|
||||
K8sEntityData,
|
||||
string | SelectedItemParams
|
||||
>['fetchListData'];
|
||||
|
||||
interface UseRelatedEntitiesParams {
|
||||
/** Absent while the target category is still unresolved; the query stays idle */
|
||||
fetchListData?: FetchRelatedEntities;
|
||||
queryKey: string;
|
||||
expression: string;
|
||||
/** Time range in seconds — see useEntityDetailsTime */
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
interface UseRelatedEntitiesResult {
|
||||
records: K8sEntityData[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
setPage: (page: number) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError | null;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
refetch: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const RELATED_ENTITIES_PAGE_SIZE = 10;
|
||||
|
||||
const SECOND_IN_MS = 1000;
|
||||
|
||||
export function useRelatedEntities({
|
||||
fetchListData,
|
||||
queryKey,
|
||||
expression,
|
||||
timeRange,
|
||||
}: UseRelatedEntitiesParams): UseRelatedEntitiesResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(RELATED_ENTITIES_PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [expression, queryKey]);
|
||||
|
||||
const reactQueryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
queryKey,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
expression,
|
||||
page,
|
||||
limit,
|
||||
],
|
||||
[queryKey, timeRange.startTime, timeRange.endTime, expression, page, limit],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: reactQueryKey,
|
||||
queryFn: ({ signal }) =>
|
||||
(fetchListData as FetchRelatedEntities)(
|
||||
{
|
||||
filter: { expression },
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
// The list APIs take milliseconds, useEntityDetailsTime gives seconds
|
||||
start: timeRange.startTime * SECOND_IN_MS,
|
||||
end: timeRange.endTime * SECOND_IN_MS,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
enabled: !!fetchListData && !!expression.trim(),
|
||||
});
|
||||
|
||||
const cancel = useCallback((): void => {
|
||||
void queryClient.cancelQueries({ queryKey: reactQueryKey });
|
||||
}, [queryClient, reactQueryKey]);
|
||||
|
||||
const handleRefetch = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return {
|
||||
records: data?.records ?? data?.data ?? [],
|
||||
total: data?.total ?? 0,
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error: data?.error,
|
||||
endTimeBeforeRetention: data?.endTimeBeforeRetention,
|
||||
refetch: handleRefetch,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Container } from '@signozhq/icons';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import { CustomTab } from '../Base/K8sBaseDetails';
|
||||
import { CustomTab } from '../Base/types';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
podUtilizationByPodWidgetInfo,
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-8);
|
||||
margin: 0;
|
||||
min-width: 900px;
|
||||
// On a 13" screen the drawer itself is narrower than 900px, and a flat
|
||||
// minimum there clips the right edge of every tab
|
||||
box-sizing: border-box;
|
||||
min-width: min(900px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +37,29 @@
|
||||
--button-padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
--button-padding: var(--spacing-2) var(--spacing-3);
|
||||
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// The kind reads as a label on the name, not as part of it
|
||||
.entityKind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: none;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import type {
|
||||
InfraMonitoringEntity,
|
||||
VIEWS,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
@@ -15,7 +18,7 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
tab: `${VIEWS}`,
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
|
||||
@@ -12,23 +12,11 @@ import {
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ArrowUpToLine,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
FilePenLine,
|
||||
Filter,
|
||||
Group,
|
||||
HardDrive,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CategoryIcon } from './Base/categoryIcons';
|
||||
import { K8sDynamicList } from './Base/K8sDynamicList';
|
||||
import {
|
||||
GetClustersQuickFiltersConfig,
|
||||
@@ -42,6 +30,7 @@ import {
|
||||
GetStatefulsetsQuickFiltersConfig,
|
||||
GetVolumesQuickFiltersConfig,
|
||||
InfraMonitoringEntity,
|
||||
K8S_CATEGORY_LABELS,
|
||||
K8sCategories,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from './constants';
|
||||
@@ -156,62 +145,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.CONTAINERS,
|
||||
label: 'Containers',
|
||||
icon: <Box size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CONTAINERS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.CONTAINERS} />,
|
||||
config: GetContainersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.PODS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.PODS} />,
|
||||
config: GetPodsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NODES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.NODES} />,
|
||||
config: GetNodesQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NAMESPACES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.NAMESPACES} />,
|
||||
config: GetNamespaceQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CLUSTERS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.CLUSTERS} />,
|
||||
config: GetClustersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DEPLOYMENTS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.DEPLOYMENTS} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.JOBS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.JOBS} />,
|
||||
config: GetJobsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DAEMONSETS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.DAEMONSETS} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.STATEFULSETS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.STATEFULSETS} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.VOLUMES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.VOLUMES} />,
|
||||
config: GetVolumesQuickFiltersConfig(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,6 +132,7 @@ export const jobEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.JOBS,
|
||||
eventCategory: InfraMonitoringEvents.Job,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'job',
|
||||
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -143,6 +143,7 @@ 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/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ 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/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
|
||||
@@ -121,6 +121,7 @@ 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/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,6 +132,7 @@ 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/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const volumeEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.VOLUMES,
|
||||
eventCategory: InfraMonitoringEvents.Volume,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'volume',
|
||||
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -157,9 +157,11 @@ export enum VIEWS {
|
||||
PROCESSES = 'processes',
|
||||
EVENTS = 'events',
|
||||
POD_METRICS = 'pod_metrics',
|
||||
OVERVIEW = 'overview',
|
||||
}
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: VIEWS.OVERVIEW,
|
||||
METRICS: VIEWS.METRICS,
|
||||
LOGS: VIEWS.LOGS,
|
||||
TRACES: VIEWS.TRACES,
|
||||
@@ -896,6 +898,41 @@ export const getInvalidValueTooltipText = (
|
||||
attribute: string,
|
||||
): string => `Some ${entity} do not have ${attribute}s.`;
|
||||
|
||||
export const K8S_CATEGORY_LABELS: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.HOSTS]: 'Hosts',
|
||||
[InfraMonitoringEntity.PODS]: 'Pods',
|
||||
[InfraMonitoringEntity.NODES]: 'Nodes',
|
||||
[InfraMonitoringEntity.NAMESPACES]: 'Namespaces',
|
||||
[InfraMonitoringEntity.CLUSTERS]: 'Clusters',
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployments',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSets',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSets',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'Containers',
|
||||
[InfraMonitoringEntity.JOBS]: 'Jobs',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'Volumes',
|
||||
};
|
||||
|
||||
/**
|
||||
* Used where one record is named, like the drawer's back control. Cased the
|
||||
* same way as the plural labels, so StatefulSet keeps its capital.
|
||||
*/
|
||||
export const K8S_CATEGORY_SINGULAR_LABELS: Record<
|
||||
InfraMonitoringEntity,
|
||||
string
|
||||
> = {
|
||||
[InfraMonitoringEntity.HOSTS]: 'Host',
|
||||
[InfraMonitoringEntity.PODS]: 'Pod',
|
||||
[InfraMonitoringEntity.NODES]: 'Node',
|
||||
[InfraMonitoringEntity.NAMESPACES]: 'Namespace',
|
||||
[InfraMonitoringEntity.CLUSTERS]: 'Cluster',
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployment',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSet',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSet',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'Container',
|
||||
[InfraMonitoringEntity.JOBS]: 'Job',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'Volume',
|
||||
};
|
||||
|
||||
export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
|
||||
{
|
||||
[InfraMonitoringEntity.HOSTS]:
|
||||
@@ -939,9 +976,11 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
PAGE_SIZE: 'pageSize',
|
||||
EXPANDED: 'expanded',
|
||||
SELECTED_ITEM: 'selectedItem',
|
||||
SELECTED_ITEM_CATEGORY: 'selectedItemCategory',
|
||||
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
|
||||
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
|
||||
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
|
||||
OVERVIEW_EXPRESSION: 'overviewExpression',
|
||||
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
|
||||
DETAIL_START_TIME: 'detailStartTime',
|
||||
DETAIL_END_TIME: 'detailEndTime',
|
||||
|
||||
@@ -18,7 +18,6 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import {
|
||||
DEFAULT_K8S_CATEGORY,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
VIEWS,
|
||||
} from './constants';
|
||||
import { orderBySchema, OrderBySchemaType } from './schemas';
|
||||
|
||||
@@ -85,10 +84,13 @@ export const useInfraMonitoringGroupBy = (): UseQueryStateReturn<
|
||||
parseAsGroupBy.withDefault([]).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringView = (): UseQueryStateReturn<string, string> =>
|
||||
export const useInfraMonitoringView = (): UseQueryStateReturn<
|
||||
string | null,
|
||||
undefined
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW,
|
||||
parseAsString.withDefault(VIEWS.METRICS).withOptions(defaultNuqsOptions),
|
||||
parseAsString.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringLogFilters = (): UseQueryStateReturn<
|
||||
@@ -137,6 +139,8 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
|
||||
|
||||
export interface SelectedItemParams {
|
||||
selectedItem: string | null;
|
||||
/** Drawer category, when it differs from the list page category. */
|
||||
category?: string | null;
|
||||
clusterName?: string | null;
|
||||
namespaceName?: string | null;
|
||||
containerName?: string | null;
|
||||
@@ -144,6 +148,7 @@ export interface SelectedItemParams {
|
||||
|
||||
const selectedItemParamsParsers = {
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
|
||||
@@ -165,6 +170,8 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
() => ({
|
||||
selectedItem:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
|
||||
category:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY] ?? null,
|
||||
clusterName:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
|
||||
null,
|
||||
@@ -183,6 +190,7 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
if (newParams === null) {
|
||||
void setRawParams({
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
|
||||
@@ -193,6 +201,8 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
void setRawParams({
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
|
||||
newParams.selectedItem ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]:
|
||||
newParams.category ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
|
||||
newParams.clusterName ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
|
||||
|
||||
@@ -190,14 +190,12 @@ function Explorer(): JSX.Element {
|
||||
className="trace-explorer-page"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
onToggleFilters={(): void => setOpen(!isOpen)}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
@@ -81,6 +82,7 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
@@ -516,13 +518,15 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
<GoToTop />
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
@@ -17,6 +18,7 @@ import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashb
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -38,6 +40,7 @@ function Explorer(): JSX.Element {
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
@@ -123,15 +126,14 @@ function Explorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
onToggleFilters={(): void => setShowQuickFilters(!showQuickFilters)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.meter }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
@@ -177,15 +179,17 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import {
|
||||
@@ -64,10 +68,12 @@ function Explorer(): JSX.Element {
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
@@ -350,12 +356,26 @@ function Explorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
showFilters={showSidebar}
|
||||
onToggleFilters={(): void => setShowSidebar(!showSidebar)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
|
||||
>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
{!showSidebar && (
|
||||
<TooltipSimple title="Show Views">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Show Views"
|
||||
prefix={<Filter size={16} />}
|
||||
onClick={(): void => setShowSidebar(true)}
|
||||
data-testid="metrics-explorer-show-sidebar"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
@@ -405,15 +425,17 @@ function Explorer(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
<MetricDetails
|
||||
metricName={selectedMetricName}
|
||||
|
||||
@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator: string;
|
||||
aggregateOperator?: string;
|
||||
initialOptions?: InitialOptions;
|
||||
}
|
||||
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -339,6 +339,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
@@ -422,7 +423,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -432,6 +433,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Logs,
|
||||
Receipt,
|
||||
Shield,
|
||||
TowerControl,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
@@ -95,6 +96,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type quick filter ID, separate multiple with comma or space',
|
||||
docsAnchor: 'quick-filter',
|
||||
},
|
||||
'saved-view': {
|
||||
label: 'Saved Views',
|
||||
description: 'Saved views in the logs, traces, metrics and meter explorers.',
|
||||
icon: TowerControl,
|
||||
selectorPlaceholder:
|
||||
'Type saved view ID, separate multiple with comma or space',
|
||||
docsAnchor: 'saved-view',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
|
||||
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Check, Trash2, X } from '@signozhq/icons';
|
||||
import { AlertDialog } from '@signozhq/ui/alert-dialog';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { DeleteSavedViewDialogProps } from './types';
|
||||
|
||||
function DeleteSavedViewDialog({
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteSavedViewDialogProps): JSX.Element {
|
||||
return (
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="narrow"
|
||||
title="Delete this view"
|
||||
titleIcon={<Trash2 size={16} />}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
prefix={<X size={12} />}
|
||||
testId="delete-saved-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={onConfirm}
|
||||
prefix={<Check size={12} />}
|
||||
testId="delete-saved-view-confirm"
|
||||
>
|
||||
Delete view
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Text>
|
||||
Deleting this view is irreversible and cannot be undone.
|
||||
</Typography.Text>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteSavedViewDialog;
|
||||
70
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
70
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
import styles from './SavedViewsMenu.module.scss';
|
||||
|
||||
function SaveChangesMenu({
|
||||
viewId,
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const updatePermission = buildSavedViewUpdatePermission(viewId);
|
||||
const { deniedPermissions } = useAuthZ([
|
||||
SavedViewCreatePermission,
|
||||
updatePermission,
|
||||
]);
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const isCreateDenied = deniedPermissions.includes(SavedViewCreatePermission);
|
||||
const isUpdateDenied = deniedPermissions.includes(updatePermission);
|
||||
return [
|
||||
{
|
||||
key: 'save-as-new',
|
||||
label: (
|
||||
<AuthZTooltip checks={[SavedViewCreatePermission]}>
|
||||
<span>Save as new view</span>
|
||||
</AuthZTooltip>
|
||||
),
|
||||
disabled: isCreateDenied,
|
||||
className: isCreateDenied ? styles.deniedItem : undefined,
|
||||
onClick: onSaveAsNew,
|
||||
},
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<AuthZTooltip checks={[updatePermission]}>
|
||||
<span>Update selected view</span>
|
||||
</AuthZTooltip>
|
||||
),
|
||||
disabled: isUpdateDenied,
|
||||
className: isUpdateDenied ? styles.deniedItem : undefined,
|
||||
onClick: onUpdate,
|
||||
},
|
||||
];
|
||||
}, [deniedPermissions, updatePermission, onSaveAsNew, onUpdate]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="end" className={styles.menu}>
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
icon={<Check size={14} />}
|
||||
color="warning"
|
||||
disabled={disabled}
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveChangesMenu;
|
||||
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
95
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
95
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { SAVE_VIEW_MODAL_TITLE } from './constants';
|
||||
import { SaveViewModalProps } from './types';
|
||||
|
||||
import styles from './SaveViewModal.module.scss';
|
||||
|
||||
const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
initialName = '',
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState(initialName);
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!displayName || isSaving) {
|
||||
return;
|
||||
}
|
||||
if (await onSave(displayName)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={SAVE_VIEW_MODAL_TITLE[mode]}
|
||||
testId="save-view-modal"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
testId="save-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isSaving}
|
||||
disabled={!displayName}
|
||||
onClick={(): void => {
|
||||
void handleSave();
|
||||
}}
|
||||
testId="save-view-submit"
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form className={styles.field} onSubmit={handleSubmit}>
|
||||
<label htmlFor={NAME_INPUT_ID}>
|
||||
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
|
||||
</label>
|
||||
<Input
|
||||
id={NAME_INPUT_ID}
|
||||
placeholder="Eg. payments-view"
|
||||
value={name}
|
||||
onChange={(event): void => setName(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
testId="save-view-name"
|
||||
/>
|
||||
</form>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewModal;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { highlightQueryExpression } from './utils/highlightQueryExpression';
|
||||
|
||||
function SavedViewHighlightedExpression({
|
||||
expression,
|
||||
}: {
|
||||
expression: string;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const parts = useMemo(
|
||||
() => highlightQueryExpression(expression, isDarkMode),
|
||||
[expression, isDarkMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map(({ from, text, color }) =>
|
||||
color ? (
|
||||
<span key={from} style={{ color }}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
text
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHighlightedExpression;
|
||||
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
.anchor {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
--popover-padding: 0;
|
||||
--popover-background: var(--l1-background);
|
||||
--popover-border-color: var(--l2-border);
|
||||
--popover-border-radius: var(--radius-2);
|
||||
--popover-foreground: var(--l1-foreground);
|
||||
|
||||
width: 324px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.queryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.queryName {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
.metric {
|
||||
color: var(--l2-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.expression {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.noFilters {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.updated {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.metaText {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
|
||||
function SavedViewHoverCard({
|
||||
target,
|
||||
onCardEnter,
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const view = target?.view;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={styles.anchor}
|
||||
style={{ top: target?.anchorTop ?? 0, height: target?.anchorHeight ?? 0 }}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={styles.card}
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>
|
||||
No filters
|
||||
</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHoverCard;
|
||||
58
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
58
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SavedViewCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SavedViewsEmptyStateProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsEmptyState({
|
||||
isSearching,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: SavedViewsEmptyStateProps): JSX.Element {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
if (isSearching) {
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-no-results">
|
||||
<Typography.Text className={styles.stateText}>
|
||||
No views match your search
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-empty">
|
||||
<Typography.Text className={styles.stateTitle}>No views yet</Typography.Text>
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Save the current view to open it later.
|
||||
</Typography.Text>
|
||||
<AuthZButton
|
||||
checks={[SavedViewCreatePermission]}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => setIsModalOpen(true)}
|
||||
data-testid="saved-views-list-create"
|
||||
>
|
||||
Create view
|
||||
</AuthZButton>
|
||||
{isModalOpen && (
|
||||
<SaveViewModal
|
||||
mode="create"
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsModalOpen(false)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsEmptyState;
|
||||
@@ -3,17 +3,76 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.isDirty {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-warning-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-error-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border-radius: var(--spacing-1);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&.isDirty {
|
||||
background: var(--callout-warning-background);
|
||||
color: var(--callout-warning-title);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
}
|
||||
}
|
||||
|
||||
.nameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--callout-warning-icon);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
rotate: 270deg;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,161 @@
|
||||
import { Menu } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { ArrowUpToLine, Menu, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_FORBIDDEN_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
import {
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
// Temp Placeholder data till main functionality is plugged in
|
||||
const PLACEHOLDER_VIEW_NAME = 'My view';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
onCollapse,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, isError, isForbidden, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useSaveNewView(createView);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
}
|
||||
}, [view, updateView]);
|
||||
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const errorName = isForbidden
|
||||
? SAVED_VIEW_FORBIDDEN_NAME
|
||||
: SAVED_VIEW_LOAD_FAILED_NAME;
|
||||
const name = isError ? errorName : (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
className={cx(styles.header, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
<Typography.Text className={styles.name}>
|
||||
{PLACEHOLDER_VIEW_NAME}
|
||||
</Typography.Text>
|
||||
{onOpenViews && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="All views"
|
||||
prefix={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
data-testid="saved-views-open"
|
||||
{isLoading ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title={name}>
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!view && !isError && (
|
||||
<SavedViewsIconButton
|
||||
title="Create new view"
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
checks={[SavedViewCreatePermission]}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
)}
|
||||
{view && !isDirty && !isError && (
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={clearView}
|
||||
testId="saved-views-clear"
|
||||
/>
|
||||
)}
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
viewId={view.id}
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
void handleUpdate();
|
||||
}}
|
||||
/>
|
||||
<SavedViewsIconButton
|
||||
title="Discard changes"
|
||||
icon={<X size={14} />}
|
||||
color="warning"
|
||||
disabled={isSaving}
|
||||
onClick={(): void => revertView(view)}
|
||||
testId="saved-views-discard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDirty && onOpenViews && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<Menu size={14} />}
|
||||
checks={[SavedViewListPermission]}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
{onCollapse && (
|
||||
<SavedViewsIconButton
|
||||
title="Collapse Filters"
|
||||
icon={<ArrowUpToLine size={14} className={styles.collapseIcon} />}
|
||||
onClick={onCollapse}
|
||||
testId="saved-views-collapse"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalMode && (
|
||||
<SaveViewModal
|
||||
mode={modalMode}
|
||||
isSaving={isSaving}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
51
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
51
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
// forwardRef and rest props so a menu can use it as its trigger.
|
||||
const SavedViewsIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SavedViewsIconButtonProps
|
||||
>(function SavedViewsIconButton(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
testId,
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
checks,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
const { deniedPermissions } = useAuthZ(checks ?? [], {
|
||||
enabled: !!checks?.length,
|
||||
});
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
color={color}
|
||||
size="icon"
|
||||
aria-label={title}
|
||||
prefix={icon}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
if (checks && deniedPermissions.length > 0) {
|
||||
return <AuthZTooltip checks={checks}>{button}</AuthZTooltip>;
|
||||
}
|
||||
return <TooltipSimple title={title}>{button}</TooltipSimple>;
|
||||
});
|
||||
|
||||
export default SavedViewsIconButton;
|
||||
132
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
132
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
.list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: var(--spacing-6);
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
flex-shrink: 0;
|
||||
max-height: 40%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: var(--spacing-16);
|
||||
padding-right: var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rowMenu {
|
||||
display: flex;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.row:hover .rowMenu,
|
||||
.row:focus-within .rowMenu,
|
||||
.rowMenu:has([data-state='open']) {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--spacing-12);
|
||||
height: var(--spacing-12);
|
||||
padding: var(--spacing-1);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
--button-height: 100%;
|
||||
--button-padding: 0 var(--spacing-6);
|
||||
--button-justify-content: flex-start;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowNameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stateTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.stateText {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
208
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
208
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
|
||||
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
|
||||
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewsList } from './hooks/useSavedViewsList';
|
||||
import SavedViewHoverCard from './SavedViewHoverCard';
|
||||
import SavedViewsEmptyState from './SavedViewsEmptyState';
|
||||
import SavedViewsListSection from './SavedViewsListSection';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import {
|
||||
PendingSavedViewAction,
|
||||
SavedViewRowActionKey,
|
||||
SavedViewsListRowHoverProps,
|
||||
} from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsList({
|
||||
source,
|
||||
search,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
useSavedViewRowActions(source);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<PendingSavedViewAction | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { deniedPermissions: deniedReadPermissions } = useAuthZ([
|
||||
SavedViewReadPermission,
|
||||
]);
|
||||
const isReadDenied = deniedReadPermissions.length > 0;
|
||||
const hoverCard = useSavedViewHoverCard();
|
||||
const {
|
||||
onRowEnter: onHoverCardRowEnter,
|
||||
onRowLeave: onHoverCardRowLeave,
|
||||
close: closeHoverCard,
|
||||
} = hoverCard;
|
||||
|
||||
useEffect(() => {
|
||||
closeHoverCard();
|
||||
}, [search, closeHoverCard]);
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
},
|
||||
onRowLeave: onHoverCardRowLeave,
|
||||
onMenuOpen: closeHoverCard,
|
||||
}),
|
||||
[onHoverCardRowEnter, onHoverCardRowLeave, closeHoverCard],
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO, key: SavedViewRowActionKey): void => {
|
||||
if (key === 'copyLink') {
|
||||
copyViewLink(view);
|
||||
return;
|
||||
}
|
||||
closeHoverCard();
|
||||
setPendingAction({ key, view });
|
||||
},
|
||||
[copyViewLink, closeHoverCard],
|
||||
);
|
||||
|
||||
const closePendingAction = useCallback((): void => setPendingAction(null), []);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!pendingAction || !(await deleteView(pendingAction.view))) {
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
clearView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading} data-testid="saved-views-list-loading">
|
||||
{Array.from({ length: SAVED_VIEWS_LIST_LOADING_ROWS }, (_, index) => (
|
||||
<Skeleton.Input key={index} active size="small" block />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-error">
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Could not load views
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
data-testid="saved-views-list-retry"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (createdByMe.length === 0 && createdByOthers.length === 0) {
|
||||
return (
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={styles.list}
|
||||
onScrollCapture={closeHoverCard}
|
||||
data-testid="saved-views-list"
|
||||
>
|
||||
{createdByMe.length > 0 && (
|
||||
<SavedViewsListSection
|
||||
title="Created by me"
|
||||
views={createdByMe}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
isReadDenied={isReadDenied}
|
||||
testId="saved-views-created-by-me"
|
||||
/>
|
||||
)}
|
||||
{createdByOthers.length > 0 && (
|
||||
<SavedViewsListSection
|
||||
title="Created by others"
|
||||
views={createdByOthers}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
isReadDenied={isReadDenied}
|
||||
testId="saved-views-created-by-others"
|
||||
/>
|
||||
)}
|
||||
<SavedViewHoverCard
|
||||
target={hoverCard.target}
|
||||
onCardEnter={hoverCard.onCardEnter}
|
||||
onCardLeave={hoverCard.onCardLeave}
|
||||
onClose={closeHoverCard}
|
||||
/>
|
||||
{pendingAction?.key === 'editDetails' && (
|
||||
<SaveViewModal
|
||||
mode="edit"
|
||||
initialName={pendingAction.view.spec.displayName}
|
||||
isSaving={isRenaming}
|
||||
onClose={closePendingAction}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
renameView(pendingAction.view, displayName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{pendingAction?.key === 'delete' && (
|
||||
<DeleteSavedViewDialog
|
||||
isDeleting={isDeleting}
|
||||
onCancel={closePendingAction}
|
||||
onConfirm={(): void => {
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsList;
|
||||
79
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
79
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
import { SavedViewsListRowProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsListRow({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
onClear,
|
||||
onAction,
|
||||
hover,
|
||||
isReadDenied,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
|
||||
const nameButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.rowName}
|
||||
onClick={(): void => {
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
}}
|
||||
data-testid="saved-views-row-name"
|
||||
>
|
||||
<span className={styles.rowNameText}>{name}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => {
|
||||
if (!isReadDenied) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
{isReadDenied ? (
|
||||
<AuthZTooltip checks={[SavedViewReadPermission]}>{nameButton}</AuthZTooltip>
|
||||
) : (
|
||||
nameButton
|
||||
)}
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
label="Clear view"
|
||||
tooltip="Clear view"
|
||||
onClick={onClear}
|
||||
testId="saved-views-row-clear"
|
||||
/>
|
||||
)}
|
||||
{!isReadDenied && (
|
||||
<span className={styles.rowMenu}>
|
||||
<SavedViewsRowMenu
|
||||
viewId={view.id}
|
||||
onAction={(key): void => onAction(view, key)}
|
||||
onOpen={hover.onMenuOpen}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsListRow;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user