mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 19:20:42 +01:00
Compare commits
4 Commits
feat/trace
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2adf3329 | ||
|
|
04db70bade | ||
|
|
54aaad2cc3 | ||
|
|
c407456be7 |
51
.github/workflows/e2eci.yaml
vendored
51
.github/workflows/e2eci.yaml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
@@ -48,12 +49,33 @@ jobs:
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
# Images bring-up-stack would otherwise pull, at the tests/conftest.py
|
||||
# defaults. A stale tag only loses the head start.
|
||||
- name: pull-images
|
||||
id: pull-images
|
||||
background: true
|
||||
run: |
|
||||
printf '%s\n' \
|
||||
node:22-bookworm \
|
||||
golang:1.25-bookworm \
|
||||
python:3.13-slim \
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.9 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: python-install
|
||||
id: python-install
|
||||
background: true
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -62,19 +84,9 @@ jobs:
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: python-install
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: pnpm-install
|
||||
run: |
|
||||
cd tests/e2e && pnpm install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
# Restore-only: the cacheci workflow owns cache saves. Seeds the
|
||||
# BuildKit cache mounts so the in-test image build is incremental.
|
||||
- name: restore
|
||||
@@ -99,6 +111,19 @@ jobs:
|
||||
tar -xf /restored/pnpm-store.tar -C /pnpm/store
|
||||
EOF
|
||||
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
|
||||
- name: wait-stack-deps
|
||||
wait: [pull-images, python-install]
|
||||
# Started after the pulls so its download overlaps the CPU-bound
|
||||
# image build instead of competing for the network.
|
||||
- name: playwright-browsers
|
||||
id: playwright-browsers
|
||||
background: true
|
||||
run: |
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
@@ -106,10 +131,12 @@ jobs:
|
||||
--basetemp=./tmp/ \
|
||||
-vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
- name: wait-playwright-browsers
|
||||
wait: playwright-browsers
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }}
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
@@ -122,6 +149,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.project }}
|
||||
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function Legend({
|
||||
focusedSeriesIndex,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
itemLabel = 'series',
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
@@ -92,11 +93,12 @@ export default function Legend({
|
||||
position={position}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
itemLabel={itemLabel}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
No {itemLabel} found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface LegendToolbarProps {
|
||||
position: LegendPosition;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
itemLabel: string;
|
||||
}
|
||||
|
||||
/** Legend chrome: the series search box and the "Showing N of M" readout. */
|
||||
@@ -23,6 +24,7 @@ export default function LegendToolbar({
|
||||
position,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
itemLabel,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
@@ -48,7 +50,7 @@ export default function LegendToolbar({
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
{`Showing ${visibleCount} of ${totalCount} ${itemLabel}`}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface LegendProps {
|
||||
onAction: OnLegendAction;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
/** Plural noun for the items in the search readouts. Default 'series'. */
|
||||
itemLabel?: string;
|
||||
}
|
||||
|
||||
/** Props for the uPlot legend controller, which derives items + interaction
|
||||
|
||||
@@ -63,20 +63,20 @@ export default function ChartWrapper({
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
|
||||
(layout: LegendLayout): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// A pie's slices and a heatmap's groups are not uPlot series.
|
||||
if (customLegend) {
|
||||
return customLegend(averageLegendWidth);
|
||||
return customLegend(layout);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
averageLegendWidth={layout.averageLegendWidth}
|
||||
showSearch={layout.showSearch}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { HeatmapChartProps } from 'lib/visualization/charts/types';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import {
|
||||
buildHeatmapConfig,
|
||||
@@ -277,11 +278,13 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
@@ -41,7 +42,7 @@ interface BaseChartProps {
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
/** Replaces the config-driven legend, for charts whose legend lists something
|
||||
* other than uPlot series — heatmap groups, where the series are bucket rows. */
|
||||
customLegend?: (averageLegendWidth: number) => React.ReactNode;
|
||||
customLegend?: (layout: LegendLayout) => React.ReactNode;
|
||||
/** Measured against for the chart/legend split. Pair with `customLegend`. */
|
||||
legendLabels?: string[];
|
||||
/** Rendered under the plot but above the legend, inside the chart column. */
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.textBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(ol),
|
||||
:global(ul) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-10);
|
||||
}
|
||||
}
|
||||
|
||||
.plainText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
border-radius: var(--radius-round);
|
||||
|
||||
&[data-tone='success'] {
|
||||
background: var(--accent-forest);
|
||||
}
|
||||
|
||||
&[data-tone='warning'] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
&[data-tone='error'] {
|
||||
background: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipSeparator {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toolCall,
|
||||
.toolResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
border-radius: var(--radius-2);
|
||||
transition: outline-color 0.3s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: var(--spacing-1);
|
||||
|
||||
&.isHighlighted {
|
||||
outline-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
|
||||
&.boxed {
|
||||
gap: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
.collapsibleToggle {
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
}
|
||||
|
||||
.collapsibleBody {
|
||||
padding: 0 var(--spacing-5) var(--spacing-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collapsibleLabel {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.collapsibleBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json {
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: auto;
|
||||
|
||||
:global(.pretty-view) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mediaChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-round);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import { ThreadMessage } from '../TraceDetailsThread/types';
|
||||
import CollapsibleBlock from './CollapsibleBlock';
|
||||
import MessagePart from './MessagePart';
|
||||
import PartChip from './PartChip';
|
||||
import { getFinishReasonTone, getRoleLabel } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
export interface AIThreadMessageProps {
|
||||
message: ThreadMessage;
|
||||
isOutput?: boolean;
|
||||
isToolLinkEnabled?: (toolCallId: string) => boolean;
|
||||
onToolLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function AIThreadMessage({
|
||||
message,
|
||||
isOutput,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: AIThreadMessageProps): JSX.Element {
|
||||
const { role, content, finishReason } = message;
|
||||
const roleLabel = getRoleLabel(role);
|
||||
const tone = getFinishReasonTone(finishReason);
|
||||
|
||||
const parts = (
|
||||
<div className={styles.parts}>
|
||||
{content.length === 0 ? (
|
||||
<span className={styles.empty}>No content</span>
|
||||
) : (
|
||||
content.map((part, index) => (
|
||||
<MessagePart
|
||||
// Parts have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
part={part}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.message}
|
||||
data-testid={`ai-thread-message-${role || 'unknown'}`}
|
||||
>
|
||||
{role === 'system' ? (
|
||||
<CollapsibleBlock label={roleLabel} testId="ai-message-system-toggle">
|
||||
{parts}
|
||||
</CollapsibleBlock>
|
||||
) : (
|
||||
<>
|
||||
{roleLabel && (
|
||||
<div className={styles.roleHeader}>
|
||||
{roleLabel}
|
||||
{isOutput && (
|
||||
<Badge color={tone} testId="ai-message-output-badge">
|
||||
Output
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parts}
|
||||
</>
|
||||
)}
|
||||
{finishReason && (
|
||||
<PartChip
|
||||
label="Finish Reason"
|
||||
value={finishReason}
|
||||
tone={tone}
|
||||
testId="ai-message-finish-reason"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AIThreadMessage.defaultProps = {
|
||||
isOutput: false,
|
||||
isToolLinkEnabled: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default AIThreadMessage;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { MouseEvent, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface CollapsibleBlockProps {
|
||||
label: ReactNode;
|
||||
// Boxed: full-width bordered row with the chevron on the right.
|
||||
isBoxed?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleBlock({
|
||||
label,
|
||||
isBoxed = false,
|
||||
testId,
|
||||
children,
|
||||
}: CollapsibleBlockProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.collapsible, isBoxed && styles.boxed)}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapsibleToggle}
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.collapsibleLabel}>{label}</span>
|
||||
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{isOpen && <div className={styles.collapsibleBody}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsibleBlock.defaultProps = {
|
||||
isBoxed: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CollapsibleBlock;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { MouseEvent, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableText({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: ExpandableTextProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
|
||||
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
className={styles.text}
|
||||
markdownContent={visibleText}
|
||||
variables={{}}
|
||||
/>
|
||||
) : (
|
||||
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
|
||||
)}
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-message-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandableText.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default ExpandableText;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface JsonBlockProps {
|
||||
data: object;
|
||||
}
|
||||
|
||||
// Expanding tree nodes must not count as a click on the message.
|
||||
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.json}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<PrettyView
|
||||
data={data as Record<string, unknown>}
|
||||
searchable={false}
|
||||
drawerKey="ai-thread-message"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonBlock;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { MessagePart as MessagePartType } from '../TraceDetailsThread/types';
|
||||
import MediaPart from './parts/MediaPart';
|
||||
import ThinkingPart from './parts/ThinkingPart';
|
||||
import ToolCallPart from './parts/ToolCallPart';
|
||||
import ToolResultPart from './parts/ToolResultPart';
|
||||
import TextContent from './TextContent';
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType;
|
||||
isToolLinkEnabled?: (toolCallId: string) => boolean;
|
||||
onToolLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: MessagePartProps): JSX.Element | null {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <TextContent text={part.content} isMarkdown />;
|
||||
case 'thinking':
|
||||
return <ThinkingPart content={part.content} />;
|
||||
case 'tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id}
|
||||
name={part.name}
|
||||
args={part.arguments}
|
||||
server={part.server}
|
||||
/>
|
||||
);
|
||||
case 'tool_result':
|
||||
return (
|
||||
<ToolResultPart
|
||||
toolCallId={part.toolCallId}
|
||||
name={part.name}
|
||||
content={part.content}
|
||||
isError={part.isError}
|
||||
isLinkEnabled={!!isToolLinkEnabled?.(part.toolCallId)}
|
||||
onLinkClick={onToolLinkClick}
|
||||
/>
|
||||
);
|
||||
case 'media':
|
||||
return (
|
||||
<MediaPart
|
||||
modality={part.modality}
|
||||
mimeType={part.mimeType}
|
||||
name={part.name}
|
||||
/>
|
||||
);
|
||||
case 'generic':
|
||||
return <TextContent text={part.content} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
MessagePart.defaultProps = {
|
||||
isToolLinkEnabled: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default MessagePart;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.chip} data-testid={testId}>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ExpandableText from './ExpandableText';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import { parseJsonObject } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface TextContentProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/** Renders JSON text as a tree, anything else as (markdown) text. */
|
||||
function TextContent({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: TextContentProps): JSX.Element {
|
||||
const json = useMemo(() => parseJsonObject(text), [text]);
|
||||
|
||||
if (json) {
|
||||
return <JsonBlock data={json} />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.textBox}>
|
||||
<ExpandableText text={text} isMarkdown={isMarkdown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextContent.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default TextContent;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Image } from '@signozhq/icons';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface MediaPartProps {
|
||||
modality?: string;
|
||||
mimeType?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function MediaPart({ modality, mimeType, name }: MediaPartProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.mediaChip} data-testid="ai-media-part">
|
||||
<Image size={12} />
|
||||
<span className="translate-safe">
|
||||
{name || mimeType || modality || 'Media'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
MediaPart.defaultProps = {
|
||||
modality: undefined,
|
||||
mimeType: undefined,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
export default MediaPart;
|
||||
@@ -1,30 +0,0 @@
|
||||
import CollapsibleBlock from '../CollapsibleBlock';
|
||||
import ExpandableText from '../ExpandableText';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ThinkingPartProps {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
function ThinkingPart({ content }: ThinkingPartProps): JSX.Element {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
label="Thinking"
|
||||
isBoxed
|
||||
testId="ai-message-thinking-toggle"
|
||||
>
|
||||
{content ? (
|
||||
<ExpandableText text={content} isMarkdown />
|
||||
) : (
|
||||
<span className={styles.empty}>Reasoning content not captured</span>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
|
||||
ThinkingPart.defaultProps = {
|
||||
content: undefined,
|
||||
};
|
||||
|
||||
export default ThinkingPart;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { parseJsonObject, TOOL_CALL_ID_ATTR } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolCallPartProps {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
server?: boolean;
|
||||
}
|
||||
|
||||
function ToolCallPart({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
server,
|
||||
}: ToolCallPartProps): JSX.Element {
|
||||
// Streamed calls can carry truncated, unparseable argument strings.
|
||||
const json = useMemo(() => {
|
||||
if (typeof args === 'string') {
|
||||
return parseJsonObject(args);
|
||||
}
|
||||
return typeof args === 'object' && args !== null ? args : undefined;
|
||||
}, [args]);
|
||||
|
||||
const toolCallAttr = { [TOOL_CALL_ID_ATTR]: id };
|
||||
|
||||
return (
|
||||
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
|
||||
<PartChip
|
||||
label="tool_call"
|
||||
value={
|
||||
<>
|
||||
<span className={styles.chipKey}>name :</span>
|
||||
<span className={styles.toolName}>{name}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{server && (
|
||||
<Badge color="secondary" variant="outline">
|
||||
server
|
||||
</Badge>
|
||||
)}
|
||||
</PartChip>
|
||||
{json ? (
|
||||
<JsonBlock data={json} />
|
||||
) : (
|
||||
args !== undefined &&
|
||||
args !== null && (
|
||||
<TextContent
|
||||
text={typeof args === 'string' ? args : JSON.stringify(args)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolCallPart.defaultProps = {
|
||||
server: false,
|
||||
};
|
||||
|
||||
export default ToolCallPart;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolResultPartProps {
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
isLinkEnabled: boolean;
|
||||
onLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function ToolResultPart({
|
||||
toolCallId,
|
||||
name,
|
||||
content,
|
||||
isError,
|
||||
isLinkEnabled,
|
||||
onLinkClick,
|
||||
}: ToolResultPartProps): JSX.Element {
|
||||
// Short plain results fit in the chip; JSON or long ones render below it.
|
||||
const isInline = useMemo(
|
||||
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const handleLinkClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
onLinkClick?.(toolCallId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolResult} data-testid="ai-tool-result">
|
||||
<PartChip
|
||||
label="tool_result"
|
||||
value={isInline ? content || '…' : name || toolCallId}
|
||||
tone={isError ? 'error' : 'success'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toolLink}
|
||||
disabled={!isLinkEnabled}
|
||||
onClick={handleLinkClick}
|
||||
title={
|
||||
isLinkEnabled ? 'Go to tool call' : 'Matching tool call is not loaded'
|
||||
}
|
||||
data-testid="ai-tool-result-link"
|
||||
>
|
||||
<CornerUpRight size={12} />
|
||||
</button>
|
||||
</PartChip>
|
||||
{!isInline && <TextContent text={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolResultPart.defaultProps = {
|
||||
name: undefined,
|
||||
isError: false,
|
||||
onLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default ToolResultPart;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
isToolLinkEnabled: (toolCallId: string) => boolean;
|
||||
onToolLinkClick: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for ids in `toolCallIds`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallIds: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const isToolLinkEnabled = useCallback(
|
||||
(toolCallId: string): boolean => toolCallIds.has(toolCallId),
|
||||
[toolCallIds],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallId: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallId)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { isToolLinkEnabled, onToolLinkClick };
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ThreadMessage } from '../TraceDetailsThread/types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error';
|
||||
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason]) || 'success';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function collectToolCallIds(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.content.flatMap((part) =>
|
||||
part.type === 'tool_call' ? [part.id] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -5,23 +5,18 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: ReactNode;
|
||||
tooltipClassName?: string;
|
||||
tooltip: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function EntityMetadataItem({
|
||||
tooltip,
|
||||
tooltipClassName,
|
||||
icon,
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: tooltipClassName }}
|
||||
>
|
||||
<TooltipSimple title={tooltip}>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
@@ -32,7 +27,6 @@ function EntityMetadataItem({
|
||||
|
||||
EntityMetadataItem.defaultProps = {
|
||||
icon: null,
|
||||
tooltipClassName: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataItem;
|
||||
|
||||
@@ -8,16 +8,3 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.tokenTooltipContent {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import { formatCost, formatTokens } from '../utils/genAi';
|
||||
import { TokenUsage } from '../SpanHoverCard/aiUsage';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -19,8 +16,6 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: TokenUsage;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -37,8 +32,6 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const durationTooltip =
|
||||
@@ -98,25 +91,6 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (tokens.input > 0 || tokens.output > 0) && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
tooltipClassName={styles.tokenTooltipContent}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{cost !== undefined && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -128,8 +102,6 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TraceTooltip/TokenBreakdown';
|
||||
import { TokenUsage } from '../SpanHoverCard/aiUsage';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: TokenUsage;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,25 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import SpanMessages from '../../SpanMessages/SpanMessages';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasAnyMessages,
|
||||
hasFormattedMessages,
|
||||
} from '../../SpanMessages/utils';
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
|
||||
import styles from './SpanAIMessages.module.scss';
|
||||
|
||||
interface SpanAIMessagesProps {
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
|
||||
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const canFormat = hasFormattedMessages(data);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallIds = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
|
||||
scrollRef,
|
||||
toolCallIds,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="span-ai-messages">
|
||||
<div className={styles.header}>
|
||||
Messages
|
||||
{canFormat && (
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId="span-ai-messages-view-toggle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.scroll} ref={scrollRef}>
|
||||
<SpanMessages
|
||||
span={span}
|
||||
view={effectiveView}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanAIMessages;
|
||||
@@ -52,7 +52,6 @@ import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -321,7 +320,6 @@ function SpanDetailsContent({
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<SpanAIMessages span={selectedSpan} />
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getSpanAiDetails, getSpanTokenUsage } from '../SpanHoverCard/aiUsage';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -33,11 +31,6 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const tokens = useMemo(() => getSpanTokenUsage(selectedSpan), [selectedSpan]);
|
||||
const cost = useMemo(
|
||||
() => getSpanAiDetails(selectedSpan)?.usage?.cost,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -70,8 +63,6 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={tokens}
|
||||
cost={cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/genAi';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanHintBadge from '../SpanHintBadge';
|
||||
|
||||
const spanWith = (
|
||||
attributes: Record<string, string | number>,
|
||||
responseStatusCode = '',
|
||||
): SpanV3 =>
|
||||
({
|
||||
attributes,
|
||||
resource: {},
|
||||
response_status_code: responseStatusCode,
|
||||
}) as unknown as SpanV3;
|
||||
|
||||
describe('SpanHintBadge', () => {
|
||||
it.each([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
|
||||
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
|
||||
])('shows only the AI badge for %p', (attributes, label) => {
|
||||
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
expect(screen.queryByText('200')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the HTTP status code for non-AI spans', () => {
|
||||
render(<SpanHintBadge span={spanWith({}, '503')} />);
|
||||
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for a non-AI span without a status code', () => {
|
||||
const { container } = render(<SpanHintBadge span={spanWith({})} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AiSpanKind } from '../utils/genAi';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -9,24 +9,40 @@
|
||||
}
|
||||
|
||||
.popover {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
width: 223px;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding-bottom: var(--spacing-4);
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -13,11 +12,6 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -44,7 +38,6 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -54,46 +47,33 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.title} style={{ color }}>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="medium">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TooltipSection>
|
||||
{ai?.model && <TooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
|
||||
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
|
||||
<TooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<TooltipSection>
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<TooltipRow
|
||||
<div
|
||||
key={row.key}
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</TooltipSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -157,7 +137,6 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getSpanAiDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { formatCost } from '../utils/genAi';
|
||||
import { SpanAiUsage } from './aiUsage';
|
||||
import TokenBreakdown from '../TraceTooltip/TokenBreakdown';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
/>
|
||||
{cost !== undefined && (
|
||||
<TooltipSection>
|
||||
<TooltipRow label="cost" value={formatCost(cost)} />
|
||||
</TooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -1,143 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getSpanAiDetails } from '../aiUsage';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard';
|
||||
|
||||
const baseProps = {
|
||||
spanName: 'chat gpt-4o',
|
||||
color: '#fff',
|
||||
hasError: false,
|
||||
relativeStartMs: 268,
|
||||
durationMs: 1110,
|
||||
};
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
describe('getSpanAiDetails', () => {
|
||||
it('returns undefined for a span without gen_ai attributes', () => {
|
||||
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads model, tool, agent, token usage and cost', () => {
|
||||
expect(
|
||||
getSpanAiDetails(
|
||||
spanWith({
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.tool.name': 'search',
|
||||
'gen_ai.agent.name': 'planner',
|
||||
'gen_ai.usage.input_tokens': '120',
|
||||
'gen_ai.usage.output_tokens': 30,
|
||||
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({
|
||||
model: 'gpt-4o',
|
||||
toolName: 'search',
|
||||
agentName: 'planner',
|
||||
usage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
cacheReadTokens: undefined,
|
||||
cacheCreationTokens: undefined,
|
||||
cost: 0.00000056,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpanTooltipContent', () => {
|
||||
it('renders status, start and duration for a non-AI span', () => {
|
||||
render(<SpanTooltipContent {...baseProps} />);
|
||||
|
||||
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
expect(screen.getByText('268 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.11 s')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders raw input, cache and output tokens with cost', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{
|
||||
model: 'gpt-4o',
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 600,
|
||||
cacheCreationTokens: 100,
|
||||
cost: 0.00000056,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
|
||||
expect(screen.getByText('Input')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cache')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cache read')).toBeInTheDocument();
|
||||
expect(screen.getByText('600')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cache write')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('Output')).toBeInTheDocument();
|
||||
expect(screen.getByText('200')).toBeInTheDocument();
|
||||
// Raw values only: no derived input or total usage.
|
||||
expect(screen.queryByText('1,700')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1,200')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides a zero cache row, and the cache group when both are zero', () => {
|
||||
const { rerender } = render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{
|
||||
usage: {
|
||||
inputTokens: 194,
|
||||
outputTokens: 32,
|
||||
cacheReadTokens: 50,
|
||||
cacheCreationTokens: 0,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Cache read')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Cache write')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{
|
||||
usage: {
|
||||
inputTokens: 194,
|
||||
outputTokens: 32,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Cache')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Cache read')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides cache rows and cost when the attributes are absent', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{ usage: { inputTokens: 10, outputTokens: 5 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Cache')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Cache read')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Cache write')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cost')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { GEN_AI_KEYS, getGenAiValue } from '../utils/genAi';
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsString(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsNumber(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getGenAiAttributeAsNumber(
|
||||
span,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
),
|
||||
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
|
||||
};
|
||||
const hasUsage =
|
||||
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
|
||||
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
|
||||
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getSpanTokenUsage(span: SpanV3): TokenUsage | undefined {
|
||||
const usage = getSpanAiDetails(span)?.usage;
|
||||
if (!usage) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
input: usage.inputTokens ?? 0,
|
||||
output: usage.outputTokens ?? 0,
|
||||
cacheRead: usage.cacheReadTokens,
|
||||
cacheWrite: usage.cacheCreationTokens,
|
||||
reasoning: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user