Compare commits

..

4 Commits

Author SHA1 Message Date
Vinicius Lourenço
0e2adf3329 ci(e2e): shard playwright across 4 runners and install browsers in background (#13077)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Two little optimizations:

- run the e2e CI shards since it's getting a little bigger (and slower)
- run parts of the steps in background
- this is something that was introduced recently by
https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/

Also adds a model pattern in `llm-o11y/llm-pricing.spec.ts`: the backend
rejects an empty `modelPattern` since #12995.

#### Additional Information

Latest run
([37721605759](https://github.com/SigNoz/signoz/actions/runs/37721605759))
against the last 10 successful single-job runs (Sep 7 to Oct 1):

| | Before (median) | After (4 shards) |
|---|---|---|
| Wall time | 26.5 min | 12.6 min |
| Playwright tests | 19.7 min | 3.7 to 5.9 min per shard |
| Job start to tests | 7.3 min | 6.3 to 7.8 min |
| Runner time | 26.5 min | 46.9 min |

- Sharding gives almost all of the gain. The previous run
([37717653047](https://github.com/SigNoz/signoz/actions/runs/37717653047))
took 12.3 min wall and 46.6 min runner time.
- Starting `playwright-browsers` after the image pulls cut its download
from 1.7 to 3.1 min down to about 50 s, but it now overlaps
`bring-up-stack`, which got about 30 s slower. Job start to tests stays
at about 7 min in both runs, so the background steps save about 20 s per
job.
2026-10-08 17:39:32 +00:00
Ashwin Bhatkal
04db70bade feat(infra-monitoring): add the overview tab to the kubernetes side drawer (#12948)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

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

#### Screen Recording


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

#### Issues closed by this PR

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

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

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

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


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

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

#### Issues closed by this PR

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


#### Screenshots

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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] : [],
),
);
}

View File

@@ -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;

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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"

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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)' },
};

View File

@@ -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);
}

View File

@@ -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),
},
};
}, [

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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