mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-17 11:50:31 +01:00
Compare commits
12 Commits
main
...
issue-1000
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349a70156e | ||
|
|
4decb41df8 | ||
|
|
0aa17540a7 | ||
|
|
585caa84fe | ||
|
|
814fd40a1d | ||
|
|
0978bdfa7f | ||
|
|
4f99261743 | ||
|
|
836988273f | ||
|
|
a622d65226 | ||
|
|
2410e3d411 | ||
|
|
654e2e4b7e | ||
|
|
03ad7a85fa |
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
init-clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.12.5
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: init-clickhouse
|
||||
command:
|
||||
- bash
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
volumes:
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.12.5
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: clickhouse
|
||||
volumes:
|
||||
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
telemetrystore-migrator:
|
||||
image: signoz/signoz-otel-collector:v0.144.6
|
||||
image: signoz/signoz-otel-collector:v0.142.0
|
||||
container_name: telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -53,6 +53,7 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- queriervariables
|
||||
- rawexportdata
|
||||
- role
|
||||
- rootuser
|
||||
@@ -60,17 +61,16 @@ jobs:
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
sqlite-mode:
|
||||
- wal
|
||||
clickhouse-version:
|
||||
- 25.5.6
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.144.6
|
||||
- v0.144.3
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
|
||||
@@ -7571,7 +7571,6 @@ components:
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
newGroupEvalDelay:
|
||||
type: string
|
||||
@@ -7643,7 +7642,6 @@ components:
|
||||
alertStates:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
nullable: true
|
||||
type: array
|
||||
enabled:
|
||||
type: boolean
|
||||
@@ -8050,15 +8048,6 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMappers:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapper'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -8211,7 +8200,7 @@ components:
|
||||
type: boolean
|
||||
fieldContext:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
groupId:
|
||||
group_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
@@ -8224,7 +8213,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- groupId
|
||||
- group_id
|
||||
- name
|
||||
- fieldContext
|
||||
- config
|
||||
@@ -13803,7 +13792,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -63,6 +63,5 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -88,6 +88,5 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -8675,9 +8675,9 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
alertStates?: RuletypesAlertStateDTO[] | null;
|
||||
alertStates?: RuletypesAlertStateDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8690,9 +8690,9 @@ export interface RuletypesRenotifyDTO {
|
||||
|
||||
export interface RuletypesNotificationSettingsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[] | null;
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9258,76 +9258,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
groupId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMappersDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpantypesSpanMapperDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -9574,6 +9504,30 @@ export interface SpantypesPostableFlamegraphDTO {
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -9622,6 +9576,45 @@ export interface SpantypesPostableWaterfallDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesUpdatableSpanMapperDTO {
|
||||
config?: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -10923,7 +10916,7 @@ export type ListSpanMappersPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMappersDTO;
|
||||
data: SpantypesGettableSpanMapperGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -19,13 +18,11 @@ import './DownloadOptionsMenu.styles.scss';
|
||||
interface DownloadOptionsMenuProps {
|
||||
dataSource: DataSource;
|
||||
selectedColumns?: TelemetryFieldKey[];
|
||||
panelType?: PANEL_TYPES;
|
||||
}
|
||||
|
||||
export default function DownloadOptionsMenu({
|
||||
dataSource,
|
||||
selectedColumns,
|
||||
panelType,
|
||||
}: DownloadOptionsMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
|
||||
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
|
||||
@@ -36,7 +33,6 @@ export default function DownloadOptionsMenu({
|
||||
|
||||
const { isDownloading, handleExportRawData } = useExportRawData({
|
||||
dataSource,
|
||||
panelType,
|
||||
});
|
||||
|
||||
const handleExport = useCallback(async (): Promise<void> => {
|
||||
|
||||
@@ -11,8 +11,6 @@ import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
@@ -150,8 +148,6 @@ export function applyCheckboxToggle({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
@@ -161,8 +157,6 @@ export function applyCheckboxToggle({
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
@@ -222,7 +216,6 @@ export function applyCheckboxToggle({
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
@@ -253,29 +246,9 @@ export function applyCheckboxToggle({
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// Related section: clicking to exclude creates NOT_IN for just this value
|
||||
if (sectionType === SectionType.RELATED) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (isArray(currentFilter.value)) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
@@ -302,38 +275,11 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in': {
|
||||
// NOT IN means "exclude these values"
|
||||
// Check if value is currently in the exclusion list
|
||||
const isValueInFilter = isArray(currentFilter.value)
|
||||
? currentFilter.value.includes(value)
|
||||
: currentFilter.value === value;
|
||||
|
||||
// When clicking unchecked "Other" item, user wants to SELECT it
|
||||
// Replace NOT IN filter with IN [value]
|
||||
if (previousState === 'unchecked' && checked) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (!checked || !isValueInFilter) {
|
||||
// Add to NOT IN when:
|
||||
// - checked=false (user explicitly unchecked to exclude)
|
||||
// - checked=true but value not in filter (clicking "other" value to exclude)
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
@@ -346,6 +292,7 @@ export function applyCheckboxToggle({
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
@@ -357,9 +304,8 @@ export function applyCheckboxToggle({
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Remove from NOT IN when value IS in filter and checked=true
|
||||
// (user wants to include this value back)
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
@@ -400,7 +346,6 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
@@ -444,11 +389,10 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No filter for this key - all are visually selected.
|
||||
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
@@ -26,8 +24,6 @@ interface UseCheckboxFilterActionsReturn {
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
@@ -57,8 +53,6 @@ function useCheckboxFilterActions({
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
@@ -70,8 +64,6 @@ function useCheckboxFilterActions({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
.checkboxFilter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-6);
|
||||
gap: var(--spacing-6);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-focus-border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
color: var(--l2-foreground);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sectionValues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.loadingValues {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-6) 0;
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.noData {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showMoreText {
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.goToDocs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.goToDocsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.goToDocsMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.goToDocsButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
margin: 0 0 var(--spacing-2);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.goToDocsButtonText {
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
}): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockFieldsValuesAPILoading(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(ctx.delay(10000)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupServer(): void {
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
}
|
||||
|
||||
export interface FilterItemConfig {
|
||||
op: string;
|
||||
value: string | string[];
|
||||
}
|
||||
|
||||
export function renderWithFilter(
|
||||
onFilterChange: jest.Mock,
|
||||
filterItem?: FilterItemConfig,
|
||||
): RenderResult {
|
||||
const items: TagFilterItem[] = filterItem
|
||||
? [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: filterItem.op,
|
||||
value: filterItem.value,
|
||||
} as TagFilterItem,
|
||||
]
|
||||
: [];
|
||||
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getFilterFromCall(
|
||||
onFilterChange: jest.Mock,
|
||||
callIndex = 0,
|
||||
): TagFilterItem | undefined {
|
||||
const query = onFilterChange.mock.calls[callIndex]?.[0] as Query | undefined;
|
||||
return query?.builder.queryData[0]?.filters?.items?.find(
|
||||
(item) => item.key?.key === 'deployment.environment',
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
import { useFieldValues } from './useFieldValues';
|
||||
import { useExistingQuery } from './useExistingQuery';
|
||||
import { isKeyMatch } from '../utils';
|
||||
|
||||
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
|
||||
import { CheckboxFilterV2Section } from './CheckboxFilterV2Section';
|
||||
import { buildSelectedSet, useSectionedValues } from './useSectionedValues';
|
||||
import { useStaleRelatedExclusions } from './useStaleRelatedExclusions';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, useFieldApis } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
// Auto-preserve open state when filter is present
|
||||
useEffect(() => {
|
||||
if (isSomeFilterPresentForCurrentAttribute && userToggleState === null) {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}, [isSomeFilterPresentForCurrentAttribute, userToggleState]);
|
||||
|
||||
const { existingQuery, hasExistingQuery } = useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
});
|
||||
|
||||
const { relatedValues, allValues, isLoading, isFetching } = useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Track if initial load completed (don't show skeleton after first load)
|
||||
// Must track if loading ever started, otherwise hasLoadedOnce gets set
|
||||
// immediately on first render when query is disabled (isLoading=false)
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const wasLoading = useRef(false);
|
||||
if (isLoading) {
|
||||
wasLoading.current = true;
|
||||
}
|
||||
if (!isLoading && wasLoading.current && !hasLoadedOnce.current) {
|
||||
hasLoadedOnce.current = true;
|
||||
}
|
||||
|
||||
// Combine for state derivation
|
||||
const attributeValues = useMemo(() => {
|
||||
const combined = [...relatedValues, ...allValues];
|
||||
return [...new Set(combined)];
|
||||
}, [relatedValues, allValues]);
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const currentFilterOp = useMemo(() => {
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
return filterSync?.op;
|
||||
}, [
|
||||
currentQuery?.builder.queryData,
|
||||
activeQueryIndex,
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
|
||||
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');
|
||||
|
||||
const selectedValues = useMemo(
|
||||
() => [
|
||||
...buildSelectedSet(
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
),
|
||||
],
|
||||
[currentFilterState, isSomeFilterPresentForCurrentAttribute, isNotInOperator],
|
||||
);
|
||||
|
||||
const isRefreshing = isFetching && !isLoading && searchText === '';
|
||||
const relatedExclusions = useStaleRelatedExclusions({
|
||||
selectedValues,
|
||||
isFetching,
|
||||
isRefreshing,
|
||||
});
|
||||
|
||||
const { sections, totalCount } = useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<section className={styles.values}>
|
||||
{sections.map((section, index) => (
|
||||
<CheckboxFilterV2Section
|
||||
key={section.type}
|
||||
section={section}
|
||||
index={index}
|
||||
isFilterDisabled={isFilterDisabled}
|
||||
filter={filter}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
isMultipleValuesTrueForTheKey={isMultipleValuesTrueForTheKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount === 0 && hasLoadedOnce.current && !isFetching && (
|
||||
<section
|
||||
className={styles.noData}
|
||||
data-testid={
|
||||
searchText
|
||||
? 'checkbox-filter-no-search-results'
|
||||
: 'checkbox-filter-empty'
|
||||
}
|
||||
>
|
||||
<Typography.Text>No values found</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleItemsCount < totalCount &&
|
||||
!(searchText && (isLoading || isFetching)) && (
|
||||
<section className={styles.showMore}>
|
||||
<Typography.Text
|
||||
className={styles.showMoreText}
|
||||
onClick={onShowMore}
|
||||
data-testid="checkbox-filter-show-more"
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.header}
|
||||
onClick={onToggleOpen}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onToggleOpen();
|
||||
}
|
||||
}}
|
||||
data-testid="checkbox-filter-header"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<section className={styles.leftAction}>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
CheckedState,
|
||||
} from 'components/QuickFilters/types';
|
||||
|
||||
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
|
||||
import { SectionDivider } from './SectionDivider';
|
||||
import { Section } from './useSectionedValues';
|
||||
import { SectionType } from './itemRules';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface SectionConfig {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
function getSectionConfig(type: SectionType): SectionConfig | null {
|
||||
switch (type) {
|
||||
case SectionType.SELECTED:
|
||||
return { label: 'Selected' };
|
||||
case SectionType.RELATED:
|
||||
return {
|
||||
label: 'Related',
|
||||
tooltip: 'Values that are filtered by your current selection.',
|
||||
};
|
||||
case SectionType.ALL_VALUES:
|
||||
return { label: 'All values' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckboxFilterV2SectionProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
isFilterDisabled: boolean;
|
||||
filter: IQuickFiltersConfig;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isMultipleValuesTrueForTheKey: boolean;
|
||||
onChange: (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnly: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Section(
|
||||
props: CheckboxFilterV2SectionProps,
|
||||
): JSX.Element | null {
|
||||
const {
|
||||
section,
|
||||
index,
|
||||
isFilterDisabled,
|
||||
filter,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isMultipleValuesTrueForTheKey,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
if (section.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getSectionConfig(section.type);
|
||||
|
||||
// Show divider for all sections except first SELECTED section
|
||||
const showDivider =
|
||||
config !== null && (index > 0 || section.type !== SectionType.SELECTED);
|
||||
|
||||
return (
|
||||
<div data-testid={`section-${section.type}`} className={styles.sectionValues}>
|
||||
{showDivider && config && (
|
||||
<SectionDivider label={config.label} tooltip={config.tooltip} />
|
||||
)}
|
||||
{section.items.map(({ value, badge, checkedState }) => {
|
||||
const isChecked = checkedState === 'checked';
|
||||
|
||||
return (
|
||||
<CheckboxFilterV2ValueRow
|
||||
key={value}
|
||||
value={value}
|
||||
checkedState={checkedState}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
badge={badge}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? isChecked && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked, previousState): void =>
|
||||
onChange(value, checked, false, previousState, section.type)
|
||||
}
|
||||
onOnlyOrAllClick={(): void => onChange(value, isChecked, true)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.valueButton {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: calc(100% - 24px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.valueLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
// Stack badge / only / toggle in a single cell so the crossfade overlaps
|
||||
// instead of laying them side-by-side mid-transition.
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.valueLabel {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.valueButton:hover {
|
||||
.onlyButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox:hover ~ .valueButton {
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badge,
|
||||
.onlyButton,
|
||||
.toggleButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicatorFalse {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.indicatorTrue {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-forest-500);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { BadgeConfig } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
import styles from './CheckboxFilterV2ValueRow.module.scss';
|
||||
|
||||
interface ValueRowProps {
|
||||
value: string;
|
||||
checkedState: CheckedState;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean, previousState: CheckedState) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
badge: BadgeConfig | null;
|
||||
}
|
||||
|
||||
function toCheckboxValue(state: CheckedState): boolean | 'indeterminate' {
|
||||
if (state === 'indeterminate') {
|
||||
return 'indeterminate';
|
||||
}
|
||||
return state === 'checked';
|
||||
}
|
||||
|
||||
const INDICATOR_CLASS_MAP = {
|
||||
false: styles.indicatorFalse,
|
||||
true: styles.indicatorTrue,
|
||||
} as Record<string, string>;
|
||||
|
||||
export function CheckboxFilterV2ValueRow({
|
||||
value,
|
||||
checkedState,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
badge,
|
||||
}: ValueRowProps): JSX.Element {
|
||||
const indicatorClass = INDICATOR_CLASS_MAP[value];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.valueRow}
|
||||
data-testid={`checkbox-value-row-${value}`}
|
||||
data-state={checkedState}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<div className={styles.checkbox}>
|
||||
<Checkbox
|
||||
onChange={(isChecked): void =>
|
||||
onCheckboxChange(isChecked === true, checkedState)
|
||||
}
|
||||
value={toCheckboxValue(checkedState)}
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
testId={`checkbox-${title}-${value}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cx(styles.valueButton, disabled && styles.isDisabled)}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onOnlyOrAllClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{indicatorClass && <div className={indicatorClass} />}
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text title={value} className={styles.valueLabel}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color={badge.color}
|
||||
className={styles.badge}
|
||||
testId={`badge-${badge.key}`}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" color="secondary" className={styles.onlyButton}>
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" className={styles.toggleButton}>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) 0;
|
||||
|
||||
margin-bottom: calc(-1 * var(--spacing-4));
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Info } from '@signozhq/icons';
|
||||
|
||||
import styles from './SectionDivider.module.scss';
|
||||
|
||||
interface SectionDividerProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function SectionDivider({
|
||||
label,
|
||||
tooltip,
|
||||
}: SectionDividerProps): JSX.Element {
|
||||
const testId = label.toLowerCase().replace(/\s+/g, '-');
|
||||
return (
|
||||
<div className={styles.divider} data-testid={`section-divider-${testId}`}>
|
||||
<div className={styles.line} />
|
||||
<Typography.Text className={styles.label}>{label}</Typography.Text>
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip}>
|
||||
<Info
|
||||
size={12}
|
||||
className={styles.infoIcon}
|
||||
data-testid="section-divider-info"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
const USE_FIELD_APIS_AUTO_DERIVE = {
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: undefined,
|
||||
};
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
const captureExistingQuery = (): Promise<string | null> =>
|
||||
new Promise((resolve) => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const existingQuery = req.url.searchParams.get('existingQuery');
|
||||
resolve(existingQuery);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['test'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('useFieldApis.existingQuery takes precedence', () => {
|
||||
it('uses useFieldApis.existingQuery when provided', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('custom.query = "value"');
|
||||
});
|
||||
|
||||
it('returns undefined when useFieldApis.existingQuery is null', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('V5 filter.expression preferred over V3 filters.items', () => {
|
||||
it('uses V5 filter.expression when both exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('v5.expression = "preferred"');
|
||||
});
|
||||
|
||||
it('uses V5 filter.expression when no V3 items exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('only.v5 = "expression"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('V3 filters.items fallback', () => {
|
||||
it('converts V3 filters.items to expression when no V5 expression exists', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api-service'");
|
||||
});
|
||||
|
||||
it('converts multiple V3 filters.items with AND operator', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api' AND env = 'prod'");
|
||||
});
|
||||
|
||||
it('returns undefined when no filters exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,768 +0,0 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - interactions', () => {
|
||||
describe('search functionality', () => {
|
||||
it('filters values based on search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let searchTextReceived = '';
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
searchTextReceived = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development']
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: values,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchTextReceived).toBe('prod');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters values via search while preserving existingQuery context', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: searchText === 'prod' ? ['production'] : [],
|
||||
stringValues: searchText === 'prod' ? ['production'] : ['staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filtered results in all_values section when searching with existingQuery', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-prod-match'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty search results message when no matches found', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-no-search-results'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows both RELATED and ALL_VALUES sections with non-overlapping values', async () => {
|
||||
// This tests the bug fix where different pod names in relatedValues vs stringValues
|
||||
// caused all items to go to ALL_VALUES instead of showing RELATED section
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
// Non-overlapping: different pod instances
|
||||
relatedValues: ['pod-a-instance-1', 'pod-b-instance-1'],
|
||||
stringValues: ['pod-a-instance-2', 'pod-b-instance-2'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for values to load
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-instance-1');
|
||||
|
||||
// RELATED section should exist with relatedValues
|
||||
expect(screen.getByTestId('section-related')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-instance-1'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-b-instance-1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ALL_VALUES section should exist with stringValues
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-instance-2'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-b-instance-2'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows both sections during search with filtered non-overlapping values', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
// Simulate API filtering - both arrays filtered but remain non-overlapping
|
||||
const relatedValues =
|
||||
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
|
||||
const stringValues =
|
||||
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues,
|
||||
stringValues,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
// After search, both sections should still appear with filtered results
|
||||
await waitFor(() => {
|
||||
// RELATED section with filtered relatedValues
|
||||
expect(screen.getByTestId('section-related')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-v1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ALL_VALUES section with filtered stringValues
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-v2'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Other values should be filtered out
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-pod-b-v1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('header interactions', () => {
|
||||
it('collapses when header clicked on open filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-production'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands when header clicked on closed filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show more functionality', () => {
|
||||
it('shows "Show More..." when more than 10 values', async () => {
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-show-more')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-value-10'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more values when "Show More..." clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-show-more'));
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-10');
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-value-14'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear functionality', () => {
|
||||
it('shows clear button when filter is open and has filter applied', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value row interactions', () => {
|
||||
it('calls onFilterChange when checkbox value clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
|
||||
await user.click(within(productionRow).getByText('production'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates NOT IN filter when unchecking related item with no existing filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with no filter, related items show as checked
|
||||
// Clicking unchecks them → creates NOT IN filter to exclude
|
||||
renderWithFilter(onFilterChange);
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toBe('valueA');
|
||||
});
|
||||
|
||||
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueB');
|
||||
});
|
||||
|
||||
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with IN filter for valueA
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['valueA'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const rowB = screen.getByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// Toggle B (unchecked -> should add to IN)
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('adds to NOT IN when toggling checked (related) with existing NOT IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with NOT IN filter for valueB
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueB'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
// Related values show as checked
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Toggle A (checked -> should add to NOT IN)
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueB', 'valueA']);
|
||||
});
|
||||
|
||||
it('creates NOT IN for single value when toggling related item with existing IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['relatedValue'],
|
||||
stringValues: ['otherValue'],
|
||||
});
|
||||
|
||||
// Start with IN filter for otherValue (Selected section)
|
||||
// relatedValue is in Related section (checked visually)
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['otherValue'] });
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-otherValue',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-relatedValue');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Toggle related item (checked -> should create NOT IN with just this value)
|
||||
await user.click(within(relatedRow).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toBe('relatedValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-rendered">{`ENV: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('custom-rendered');
|
||||
expect(screen.getByText('ENV: production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,499 +0,0 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - item rules', () => {
|
||||
describe('no existing query', () => {
|
||||
it('all values show as checked with no badge when no query exists', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// No section dividers when no existing query (all in "selected" section)
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-all-values'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with existing query (related values)', () => {
|
||||
it('shows related values in "Related" section with checked state', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
|
||||
// Related values show as checked in "Related" section
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Other values show as unchecked in "All values" section
|
||||
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('shows no badge for values not in relatedValues (unchecked state in All values section)', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const stagingRow = await screen.findByTestId('checkbox-value-row-staging');
|
||||
expect(within(stagingRow).getByText('staging')).toBeInTheDocument();
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related values as checked when hasFilterForThisKey=true and isInRelatedValues=true', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production', 'staging'],
|
||||
stringValues: ['development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
// production is selected - in selected section
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// staging is related - in related section with checked state
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with IN operator', () => {
|
||||
it('shows checked state with no badge for IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with NOT IN operator', () => {
|
||||
it('shows unchecked state with no badge for NOT_IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section ordering', () => {
|
||||
it('orders selected section before related section before other section', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-value'],
|
||||
stringValues: ['other-value', 'selected-value'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-selected-value');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values[0]).toBe('selected-value');
|
||||
expect(values[1]).toBe('related-value');
|
||||
expect(values[2]).toBe('other-value');
|
||||
});
|
||||
|
||||
it('sorts alphabetically within same section', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['zebra', 'alpha', 'mike'],
|
||||
stringValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-alpha');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['alpha', 'mike', 'zebra']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed state scenarios', () => {
|
||||
it('handles mixed state: IN-selected + related + other in same list', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'selected-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-selected-env',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(within(selectedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
// Related values now show as checked
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
const otherRow = screen.getByTestId('checkbox-value-row-other-env');
|
||||
expect(otherRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(otherRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles NOT_IN-selected alongside related values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'excluded-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const excludedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-excluded-env',
|
||||
);
|
||||
expect(excludedRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(excludedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
// Related values show as checked
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - states', () => {
|
||||
describe('loading states', () => {
|
||||
it('shows skeleton while loading initial data', async () => {
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-v2')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skeleton when initially closed filter is opened for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
const closedFilter = { ...DEFAULT_FILTER, defaultOpen: false };
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Filter starts closed - no skeleton, no content
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Click header to open
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await user.click(header);
|
||||
|
||||
// Should show skeleton while loading, NOT "No values found"
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search spinner when fetching after initial load', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.delay(10000));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-loading'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty states', () => {
|
||||
it('shows "No values found" when API returns empty arrays', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: [],
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emptySection = await screen.findByTestId('checkbox-filter-empty');
|
||||
expect(emptySection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value rendering', () => {
|
||||
it('renders values from API response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-development'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders number values converted to strings', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
numberValues: [200, 404, 500],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row200 = await screen.findByTestId('checkbox-value-row-200');
|
||||
expect(within(row200).getByText('200')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-404')).getByText('404'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-500')).getByText('500'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters null/undefined values from response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['valid', null, '', undefined as unknown as string],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const validRow = await screen.findByTestId('checkbox-value-row-valid');
|
||||
expect(within(validRow).getByText('valid')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId(/^checkbox-value-row-/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CheckboxFilterV2Header } from '../CheckboxFilterV2Header';
|
||||
|
||||
describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('collapsed state', () => {
|
||||
it('renders title', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.getByText('Environment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets data-state="closed" when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanded state', () => {
|
||||
it('sets data-state="open" when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('calls onToggleOpen on header click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-header'));
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,297 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { BadgeConfig } from '../itemRules';
|
||||
import { CheckedState } from '../../../../types';
|
||||
import { CheckboxFilterV2ValueRow } from '../CheckboxFilterV2ValueRow';
|
||||
|
||||
describe('CheckboxFilterV2ValueRow', () => {
|
||||
const defaultProps = {
|
||||
value: 'production',
|
||||
checkedState: 'unchecked' as CheckedState,
|
||||
disabled: false,
|
||||
title: 'Environment',
|
||||
onlyButtonLabel: 'Only',
|
||||
onCheckboxChange: jest.fn(),
|
||||
onOnlyOrAllClick: jest.fn(),
|
||||
badge: null as BadgeConfig | null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checked states', () => {
|
||||
it('sets data-state="unchecked" for unchecked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('sets data-state="checked" for checked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="checked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge variations', () => {
|
||||
it('renders no badge when badge=null', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} badge={null} />);
|
||||
|
||||
expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Not in" warning badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Related" robin badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'related', label: 'Related', color: 'robin' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
expect(screen.getByText('Related')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Other" secondary badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('only/all button label', () => {
|
||||
it('shows "Only" label by default', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="Only" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "All" label when appropriate', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="All" />);
|
||||
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('sets data-disabled=true when disabled', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} disabled />);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick when disabled + clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick on keydown when disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByText('production').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('special value indicators', () => {
|
||||
it('renders row for "true" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="true" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-true')).toBeInTheDocument();
|
||||
expect(screen.getByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for "false" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="false" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-false')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for regular values', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="production" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('renders checkbox with correct testId', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-Environment-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on value text click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard('{Enter}');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard(' ');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Toggle button', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', () => {
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-render">{`Custom: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
customRendererForValue={customRenderer}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-render')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom: production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default value text when no custom renderer', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state combinations', () => {
|
||||
it('checked + not_in badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
checkedState="unchecked"
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled + badge still shows badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { deriveItemConfig, ItemContext, SectionType } from '../itemRules';
|
||||
|
||||
describe('itemRules', () => {
|
||||
describe('deriveItemConfig', () => {
|
||||
it('no query at all → section selected, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + IN operator → section selected, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + NOT IN operator → section selected, no badge, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('has query, not selected, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, has filter for this key, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, not in related → section all_values, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('has query + filter for key, not selected, not in related → section all_values, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('no query but has filter for key, not selected → fallback to checked (DEFAULT_CONFIG)', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,382 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { SectionType } from '../itemRules';
|
||||
import { useSectionedValues, SectionedItem } from '../useSectionedValues';
|
||||
|
||||
function flattenSections(
|
||||
sections: { type: SectionType; items: SectionedItem[] }[],
|
||||
): SectionedItem[] {
|
||||
return sections.flatMap((s) => s.items);
|
||||
}
|
||||
|
||||
describe('useSectionedValues', () => {
|
||||
const baseInput = {
|
||||
relatedValues: ['val1', 'val2'],
|
||||
allValues: ['val1', 'val2', 'val3'],
|
||||
currentFilterState: {},
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
visibleItemsCount: 10,
|
||||
relatedExclusions: [] as string[],
|
||||
};
|
||||
|
||||
it('no query at all → all items in selected section, no badges', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sections).toHaveLength(1);
|
||||
expect(result.current.sections[0].type).toBe(SectionType.SELECTED);
|
||||
expect(result.current.sections[0].items).toHaveLength(3);
|
||||
result.current.sections[0].items.forEach((item) => {
|
||||
expect(item.badge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, no filter for key → related and all_values sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const relatedItems = allItems.filter(
|
||||
(item) => item.value === 'val1' || item.value === 'val2',
|
||||
);
|
||||
const otherItems = allItems.filter((item) => item.value === 'val3');
|
||||
|
||||
// Related values should be in related section, checked
|
||||
relatedItems.forEach((item) => {
|
||||
expect(item.section).toBe(SectionType.RELATED);
|
||||
expect(item.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
// Other values should be in all_values section, unchecked
|
||||
otherItems.forEach((item) => {
|
||||
expect(item.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(item.checkedState).toBe('unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
it('has query + filter for key, selected value → in selected section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true, val2: false, val3: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const selectedItem = allItems.find((item) => item.value === 'val1');
|
||||
|
||||
expect(selectedItem?.section).toBe(SectionType.SELECTED);
|
||||
expect(selectedItem?.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('has query + filter for key, NOT IN operator → excluded values in selected section, no badge', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
isNotInOperator: true,
|
||||
currentFilterState: { val1: false, val2: true, val3: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
// val1 is unchecked + NOT IN = excluded
|
||||
const excludedItem = allItems.find((item) => item.value === 'val1');
|
||||
|
||||
expect(excludedItem?.section).toBe(SectionType.SELECTED);
|
||||
expect(excludedItem?.badge).toBeNull();
|
||||
expect(excludedItem?.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('items within same section sorted alphabetically', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['zebra', 'apple', 'mango'],
|
||||
allValues: ['zebra', 'apple', 'mango'],
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// All items have section selected, should be sorted alphabetically
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const values = allItems.map((item) => item.value);
|
||||
expect(values).toStrictEqual(['apple', 'mango', 'zebra']);
|
||||
});
|
||||
|
||||
describe('filtered results from API', () => {
|
||||
it('keeps items in their natural sections with filtered API results', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
expect(sectionTypes).toContain(SectionType.SELECTED);
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
});
|
||||
|
||||
it('returns empty sections array when no values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: [],
|
||||
allValues: [],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
currentFilterState: {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sections).toHaveLength(0);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('non-related items go to ALL_VALUES section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: [],
|
||||
allValues: ['other1', 'other2', 'other3'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles non-overlapping relatedValues and allValues correctly', () => {
|
||||
// This tests the bug where different pod names in relatedValues vs allValues
|
||||
// caused all items to go to ALL_VALUES instead of RELATED
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
|
||||
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
// RELATED section should exist with relatedValues items
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
expect(relatedSection?.items).toHaveLength(3);
|
||||
expect(relatedSection?.items.map((i) => i.value)).toStrictEqual(
|
||||
expect.arrayContaining(['pod-a-1', 'pod-b-1', 'pod-c-1']),
|
||||
);
|
||||
|
||||
// ALL_VALUES section should exist with allValues items
|
||||
expect(sectionTypes).toContain(SectionType.ALL_VALUES);
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(3);
|
||||
expect(allValuesSection?.items.map((i) => i.value)).toStrictEqual(
|
||||
expect.arrayContaining(['pod-a-2', 'pod-b-2', 'pod-c-2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale related values during refresh (relatedExclusions)', () => {
|
||||
// Scenario: key A selected (existing query) + key B had value "oldSelected".
|
||||
// User selects a different value on key B -> "oldSelected" is de-selected.
|
||||
// keepPreviousData keeps the stale response where "oldSelected" is still in
|
||||
// relatedValues, so it would wrongly render as "related". It is excluded.
|
||||
it('subtracts only the excluded value from the RELATED section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
// stale API data kept via keepPreviousData
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
|
||||
// oldSelected must NOT render as related (dropped entirely - not in
|
||||
// allValues nor selected)
|
||||
expect(
|
||||
allItems.find((item) => item.value === 'oldSelected'),
|
||||
).toBeUndefined();
|
||||
|
||||
// other related values are kept
|
||||
expect(allItems.find((item) => item.value === 'otherRelated')?.section).toBe(
|
||||
SectionType.RELATED,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves every related value that is not the excluded (previously selected) one', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
// oldSelected was just de-selected; the rest are genuinely related
|
||||
relatedValues: ['oldSelected', 'relatedA', 'relatedB', 'relatedC'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
}),
|
||||
);
|
||||
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
const relatedValues = relatedSection?.items.map((i) => i.value) ?? [];
|
||||
|
||||
// all non-excluded related values are kept
|
||||
expect(relatedValues).toStrictEqual(['relatedA', 'relatedB', 'relatedC']);
|
||||
// and they stay checked
|
||||
relatedSection?.items.forEach((item) => {
|
||||
expect(item.checkedState).toBe('checked');
|
||||
});
|
||||
// the excluded value is gone
|
||||
expect(relatedValues).not.toContain('oldSelected');
|
||||
});
|
||||
|
||||
it('keeps the RELATED section when other related values remain', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
expect(sectionTypes).toContain(SectionType.SELECTED);
|
||||
});
|
||||
|
||||
it('shows the excluded value normally when exclusions are empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
expect(allItems.find((item) => item.value === 'oldSelected')?.section).toBe(
|
||||
SectionType.RELATED,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section ordering', () => {
|
||||
it('sections appear in order: SELECTED → RELATED → ALL_VALUES', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['related1'],
|
||||
allValues: ['all1'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { selected1: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
// Verify order
|
||||
const selectedIdx = sectionTypes.indexOf(SectionType.SELECTED);
|
||||
const relatedIdx = sectionTypes.indexOf(SectionType.RELATED);
|
||||
const allValuesIdx = sectionTypes.indexOf(SectionType.ALL_VALUES);
|
||||
|
||||
expect(selectedIdx).toBeLessThan(relatedIdx);
|
||||
expect(relatedIdx).toBeLessThan(allValuesIdx);
|
||||
});
|
||||
|
||||
it('RELATED section appears before ALL_VALUES even with many items', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
expect(sectionTypes[0]).toBe(SectionType.RELATED);
|
||||
expect(sectionTypes[1]).toBe(SectionType.ALL_VALUES);
|
||||
});
|
||||
|
||||
it('visibleItemsCount limits total items across sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['r1', 'r2', 'r3'],
|
||||
allValues: ['a1', 'a2', 'a3'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const totalItems = result.current.sections.reduce(
|
||||
(sum, s) => sum + s.items.length,
|
||||
0,
|
||||
);
|
||||
expect(totalItems).toBe(4);
|
||||
|
||||
// RELATED section should get priority (3 items)
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
expect(relatedSection?.items).toHaveLength(3);
|
||||
|
||||
// ALL_VALUES gets remaining (1 item)
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
export enum SectionType {
|
||||
SELECTED = 'selected',
|
||||
RELATED = 'related',
|
||||
ALL_VALUES = 'all_values',
|
||||
}
|
||||
|
||||
export interface BadgeConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
color: 'robin' | 'warning' | 'secondary';
|
||||
}
|
||||
|
||||
export interface ItemConfig {
|
||||
section: SectionType;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface ItemContext {
|
||||
isSelectedOnFilter: boolean;
|
||||
isInRelatedValues: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
hasFilterForThisKey: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedItem extends ItemConfig {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ItemRule {
|
||||
condition: (ctx: ItemContext) => boolean;
|
||||
config: ItemConfig;
|
||||
}
|
||||
|
||||
const ITEM_RULES: ItemRule[] = [
|
||||
// No existing query and no filter → all checked (selected section)
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
!ctx.hasExistingQuery && !ctx.hasFilterForThisKey,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Selected with NOT IN operator → unchecked, no badge
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
// Selected with IN operator → checked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Related values (from existing query) → checked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery && ctx.isInRelatedValues,
|
||||
config: {
|
||||
section: SectionType.RELATED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// All values (has existing query but not related) → unchecked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback when no rule matches
|
||||
const DEFAULT_CONFIG: ItemConfig = {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
};
|
||||
|
||||
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
|
||||
for (const rule of ITEM_RULES) {
|
||||
if (rule.condition(ctx)) {
|
||||
return rule.config;
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export function deriveItems(
|
||||
values: string[],
|
||||
relatedSet: Set<string>,
|
||||
selectedOnFilterSet: Set<string>,
|
||||
ctx: Omit<ItemContext, 'isSelectedOnFilter' | 'isInRelatedValues'>,
|
||||
): DerivedItem[] {
|
||||
return values.map((value) => {
|
||||
const itemCtx: ItemContext = {
|
||||
...ctx,
|
||||
isSelectedOnFilter: selectedOnFilterSet.has(value),
|
||||
isInRelatedValues: relatedSet.has(value),
|
||||
};
|
||||
const config = deriveItemConfig(itemCtx);
|
||||
return { value, ...config };
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
|
||||
|
||||
interface UseExistingQueryParams {
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseExistingQueryResult {
|
||||
existingQuery: string | undefined;
|
||||
hasExistingQuery: boolean;
|
||||
}
|
||||
|
||||
export function useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
}: UseExistingQueryParams): UseExistingQueryResult {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const existingQuery = useMemo(() => {
|
||||
if (useFieldApis.existingQuery === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useFieldApis.existingQuery) {
|
||||
return useFieldApis.existingQuery;
|
||||
}
|
||||
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
|
||||
// Prefer V5 filter.expression
|
||||
if (queryData?.filter?.expression) {
|
||||
return queryData.filter.expression;
|
||||
}
|
||||
|
||||
// Fall back to V3 filters.items
|
||||
if (queryData?.filters?.items?.length) {
|
||||
return convertFiltersToExpression(queryData.filters).expression;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
useFieldApis.existingQuery,
|
||||
currentQuery.builder.queryData,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// Check if ANY filters exist in query (V3 items or V5 expression)
|
||||
// This is separate from existingQuery because existingQuery can be explicitly
|
||||
// disabled (null) while filters still exist in the query for UI purposes
|
||||
const hasExistingQuery = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
|
||||
const hasV5Expression = !!queryData?.filter?.expression;
|
||||
return hasV3Items || hasV5Expression || !!existingQuery;
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
|
||||
return { existingQuery, hasExistingQuery };
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseFieldValuesReturn {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
export function useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const relatedValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
values.relatedValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || []
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stringValues =
|
||||
values.stringValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || [];
|
||||
const numberValues =
|
||||
values.numberValues
|
||||
?.filter((value): value is number => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BadgeConfig, deriveItems, SectionType } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
interface SectionedValuesInput {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
visibleItemsCount: number;
|
||||
relatedExclusions: string[];
|
||||
}
|
||||
|
||||
export interface SectionedItem {
|
||||
value: string;
|
||||
section: SectionType;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
type: SectionType;
|
||||
items: SectionedItem[];
|
||||
}
|
||||
|
||||
interface SectionedValuesOutput {
|
||||
sections: Section[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const SECTION_ORDER: SectionType[] = [
|
||||
SectionType.SELECTED,
|
||||
SectionType.RELATED,
|
||||
SectionType.ALL_VALUES,
|
||||
];
|
||||
|
||||
export function buildSelectedSet(
|
||||
currentFilterState: Record<string, boolean>,
|
||||
isSomeFilterPresentForCurrentAttribute: boolean,
|
||||
isNotInOperator: boolean,
|
||||
): Set<string> {
|
||||
const selectedSet = new Set<string>();
|
||||
if (!isSomeFilterPresentForCurrentAttribute) {
|
||||
return selectedSet;
|
||||
}
|
||||
|
||||
for (const [val, isChecked] of Object.entries(currentFilterState)) {
|
||||
// NOT IN: unchecked = explicitly excluded
|
||||
// IN: checked = explicitly selected
|
||||
const shouldAdd = isNotInOperator ? !isChecked : isChecked;
|
||||
if (shouldAdd) {
|
||||
selectedSet.add(val);
|
||||
}
|
||||
}
|
||||
return selectedSet;
|
||||
}
|
||||
|
||||
export function useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
}: SectionedValuesInput): SectionedValuesOutput {
|
||||
const items = useMemo(() => {
|
||||
const selectedSet = buildSelectedSet(
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
);
|
||||
|
||||
const exclusionSet = new Set(relatedExclusions);
|
||||
const effectiveRelatedValues = relatedValues.filter(
|
||||
(value) => !exclusionSet.has(value),
|
||||
);
|
||||
|
||||
// Combine all values - API already filters both arrays by searchText
|
||||
const allUniqueValues = Array.from(
|
||||
new Set([...effectiveRelatedValues, ...allValues]),
|
||||
);
|
||||
|
||||
// Include selected values at top - may not be in API response
|
||||
const finalValues = [
|
||||
...new Set([...Array.from(selectedSet), ...allUniqueValues]),
|
||||
];
|
||||
const relatedSet = new Set(effectiveRelatedValues);
|
||||
|
||||
return deriveItems(finalValues, relatedSet, selectedSet, {
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
|
||||
});
|
||||
}, [
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
relatedExclusions,
|
||||
]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
// Group items by section
|
||||
const sectionMap = new Map<SectionType, SectionedItem[]>();
|
||||
for (const sectionType of SECTION_ORDER) {
|
||||
sectionMap.set(sectionType, []);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
sectionMap.get(item.section)?.push(item);
|
||||
}
|
||||
|
||||
// Sort items within each section alphabetically
|
||||
for (const sectionItems of sectionMap.values()) {
|
||||
sectionItems.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
// Apply visibleItemsCount across all sections
|
||||
let remaining = visibleItemsCount;
|
||||
const result: Section[] = [];
|
||||
|
||||
for (const sectionType of SECTION_ORDER) {
|
||||
const sectionItems = sectionMap.get(sectionType) || [];
|
||||
|
||||
if (sectionItems.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemsToTake = Math.min(sectionItems.length, remaining);
|
||||
if (itemsToTake === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
result.push({
|
||||
type: sectionType,
|
||||
items: sectionItems.slice(0, itemsToTake),
|
||||
});
|
||||
remaining -= itemsToTake;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, visibleItemsCount]);
|
||||
|
||||
return { sections, totalCount: items.length };
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
const EMPTY: string[] = [];
|
||||
|
||||
interface UseStaleRelatedExclusionsParams {
|
||||
selectedValues: string[];
|
||||
isFetching: boolean;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export function useStaleRelatedExclusions({
|
||||
selectedValues,
|
||||
isFetching,
|
||||
isRefreshing,
|
||||
}: UseStaleRelatedExclusionsParams): string[] {
|
||||
const selectionAtLastFetchRef = useRef<string[]>(selectedValues);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching) {
|
||||
selectionAtLastFetchRef.current = selectedValues;
|
||||
}
|
||||
}, [isFetching, selectedValues]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isRefreshing) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const current = new Set(selectedValues);
|
||||
const removed = selectionAtLastFetchRef.current.filter(
|
||||
(value) => !current.has(value),
|
||||
);
|
||||
|
||||
return removed.length ? removed : EMPTY;
|
||||
}, [isRefreshing, selectedValues]);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
import Slider from './FilterRenderers/Slider/Slider';
|
||||
import useFilterConfig from './hooks/useFilterConfig';
|
||||
@@ -52,7 +51,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const { user } = useAppContext();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
@@ -299,45 +297,21 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
{filterConfig.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case FiltersType.CHECKBOX:
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.DURATION:
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
return <Duration filter={filter} onFilterChange={onFilterChange} />;
|
||||
case FiltersType.SLIDER:
|
||||
return <Slider key={filter.attributeKey.key} />;
|
||||
return <Slider />;
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
@@ -407,5 +381,4 @@ QuickFilters.defaultProps = {
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
useFieldApis: undefined,
|
||||
};
|
||||
|
||||
@@ -26,11 +26,6 @@ export enum SignalType {
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
|
||||
*/
|
||||
export type CheckedState = 'checked' | 'unchecked' | 'indeterminate';
|
||||
|
||||
export interface IQuickFiltersConfig {
|
||||
type: FiltersType;
|
||||
title: string;
|
||||
@@ -51,7 +46,6 @@ export interface IQuickFiltersProps {
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
@@ -62,19 +56,3 @@ export enum QuickFiltersSource {
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
|
||||
*/
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
/**
|
||||
* If you didn't specify a string, we automatically try to extract this from the currentQuery,
|
||||
* from the filter.expression or filter.items.
|
||||
*
|
||||
* Use null to ignore/disable this behavior.
|
||||
*/
|
||||
existingQuery?: string | null;
|
||||
metricNamespace?: string;
|
||||
};
|
||||
|
||||
@@ -11,17 +11,17 @@ import { FlatItem, TableRowContext } from './types';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type VirtuosoTableRowProps<TData, TItemKey = string> = ComponentProps<
|
||||
type VirtuosoTableRowProps<TData> = ComponentProps<
|
||||
NonNullable<
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['TableRow']
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
|
||||
>
|
||||
>;
|
||||
|
||||
function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
function TanStackCustomTableRow<TData>({
|
||||
item,
|
||||
context,
|
||||
...props
|
||||
}: VirtuosoTableRowProps<TData, TItemKey>): JSX.Element {
|
||||
}: VirtuosoTableRowProps<TData>): JSX.Element {
|
||||
const rowId = item.row.id;
|
||||
const rowData = item.row.original;
|
||||
|
||||
@@ -84,9 +84,9 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
// This looks overkill but ensures the table is stable and doesn't re-render on every change
|
||||
// If you add any new prop to context, remember to update this function
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
prev: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
|
||||
next: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
|
||||
function areTableRowPropsEqual<TData>(
|
||||
prev: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
next: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
): boolean {
|
||||
if (prev.item.row.id !== next.item.row.id) {
|
||||
return false;
|
||||
@@ -141,9 +141,7 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
return true;
|
||||
}
|
||||
|
||||
export default memo(TanStackCustomTableRow, areTableRowPropsEqual as any) as <
|
||||
TData,
|
||||
TItemKey = string,
|
||||
>(
|
||||
props: VirtuosoTableRowProps<TData, TItemKey>,
|
||||
) => JSX.Element;
|
||||
export default memo(
|
||||
TanStackCustomTableRow,
|
||||
areTableRowPropsEqual,
|
||||
) as typeof TanStackCustomTableRow;
|
||||
|
||||
@@ -8,23 +8,23 @@ import { TableRowContext } from './types';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type TanStackRowCellsProps<TData, TItemKey = string> = {
|
||||
type TanStackRowCellsProps<TData> = {
|
||||
row: TanStackRowModel<TData>;
|
||||
context: TableRowContext<TData, TItemKey> | undefined;
|
||||
context: TableRowContext<TData> | undefined;
|
||||
itemKind: 'row' | 'expansion';
|
||||
hasSingleColumn: boolean;
|
||||
columnOrderKey: string;
|
||||
columnVisibilityKey: string;
|
||||
};
|
||||
|
||||
function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
function TanStackRowCellsInner<TData>({
|
||||
row,
|
||||
context,
|
||||
itemKind,
|
||||
hasSingleColumn,
|
||||
columnOrderKey: _columnOrderKey,
|
||||
columnVisibilityKey: _columnVisibilityKey,
|
||||
}: TanStackRowCellsProps<TData, TItemKey>): JSX.Element {
|
||||
}: TanStackRowCellsProps<TData>): JSX.Element {
|
||||
const hasHovered = useIsRowHovered(row.id);
|
||||
const rowData = row.original;
|
||||
const visibleCells = row.getVisibleCells();
|
||||
@@ -40,10 +40,8 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLTableCellElement>) => {
|
||||
// Fall back to an empty key so row clicks still fire for consumers
|
||||
// that don't provide getRowKey (e.g. Logs Explorer / Live Logs).
|
||||
const keyData = getRowKeyData?.(rowIndex);
|
||||
const itemKey = keyData?.itemKey ?? ('' as TItemKey);
|
||||
const itemKey = keyData?.itemKey ?? '';
|
||||
|
||||
// Handle ctrl+click or cmd+click (open in new tab)
|
||||
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
|
||||
@@ -133,8 +131,6 @@ function areRowCellsPropsEqual<TData>(
|
||||
const TanStackRowCells = memo(
|
||||
TanStackRowCellsInner,
|
||||
areRowCellsPropsEqual as any,
|
||||
) as <T, TItemKey = string>(
|
||||
props: TanStackRowCellsProps<T, TItemKey>,
|
||||
) => JSX.Element;
|
||||
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
|
||||
|
||||
export default TanStackRowCells;
|
||||
|
||||
@@ -67,7 +67,7 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||
const noopColumnVisibility = (): void => {};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData, TItemKey = string>(
|
||||
function TanStackTableInner<TData>(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
@@ -107,7 +107,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
suffixPaginationContent,
|
||||
enableAlternatingRowColors,
|
||||
disableVirtualScroll,
|
||||
}: TanStackTableProps<TData, TItemKey>,
|
||||
}: TanStackTableProps<TData>,
|
||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||
): JSX.Element {
|
||||
if (disableVirtualScroll && onEndReached) {
|
||||
@@ -193,7 +193,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
skeletonRowCount,
|
||||
});
|
||||
|
||||
const { rowKeyData, getRowKeyData } = useRowKeyData<TData, TItemKey>({
|
||||
const { rowKeyData, getRowKeyData } = useRowKeyData({
|
||||
data: effectiveData,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
@@ -229,7 +229,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
|
||||
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
);
|
||||
@@ -356,7 +356,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[effectiveVisibility, columnIds],
|
||||
);
|
||||
|
||||
const virtuosoContext = useMemo<TableRowContext<TData, TItemKey>>(
|
||||
const virtuosoContext = useMemo<TableRowContext<TData>>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
@@ -520,15 +520,13 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
);
|
||||
|
||||
type VirtuosoTableComponentProps = ComponentProps<
|
||||
NonNullable<
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['Table']
|
||||
>
|
||||
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
|
||||
>;
|
||||
|
||||
// Use refs in virtuosoComponents to keep the component reference stable during resize
|
||||
// This prevents Virtuoso from re-rendering all rows when columns are resized
|
||||
const virtuosoComponents = useMemo(
|
||||
(): TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>> => ({
|
||||
() => ({
|
||||
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
|
||||
<table className={tableStyles.tanStackTable} style={style}>
|
||||
<VirtuosoTableColGroup
|
||||
@@ -584,7 +582,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData, TItemKey>>
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||
className={virtuosoClassName}
|
||||
ref={virtuosoRef}
|
||||
{...restTableScrollerProps}
|
||||
@@ -662,11 +660,8 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
);
|
||||
}
|
||||
|
||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <
|
||||
TData,
|
||||
TItemKey = string,
|
||||
>(
|
||||
props: TanStackTableProps<TData, TItemKey> & {
|
||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
|
||||
props: TanStackTableProps<TData> & {
|
||||
ref?: React.Ref<TanStackTableHandle>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
|
||||
@@ -57,39 +57,6 @@ describe('TanStackRowCells', () => {
|
||||
});
|
||||
|
||||
it('calls onRowClick when a cell is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
// Mirrors Logs Explorer / Live Logs, which set onRowClick but no getRowKey.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
@@ -117,6 +84,7 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
});
|
||||
|
||||
@@ -129,7 +97,6 @@ describe('TanStackRowCells', () => {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
@@ -227,7 +194,6 @@ describe('TanStackRowCells', () => {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
@@ -250,7 +216,7 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -261,7 +227,6 @@ describe('TanStackRowCells', () => {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
@@ -284,7 +249,7 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -295,7 +260,6 @@ describe('TanStackRowCells', () => {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
|
||||
@@ -614,34 +614,6 @@ describe('TanStackTableView Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onRowClick with object itemKey when getItemKey returns object', async () => {
|
||||
type SelectionParams = { id: string; name: string };
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn<void, [unknown, SelectionParams]>();
|
||||
|
||||
renderTanStackTable<
|
||||
(typeof import('./testUtils').defaultData)[0],
|
||||
SelectionParams
|
||||
>({
|
||||
props: {
|
||||
onRowClick,
|
||||
getRowKey: (row) => row.id,
|
||||
getItemKey: (row) => ({ id: row.id, name: row.name }),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
|
||||
@@ -42,14 +42,14 @@ export const defaultData: TestRow[] = [
|
||||
{ id: '3', name: 'Item 3', value: 300 },
|
||||
];
|
||||
|
||||
export type RenderTanStackTableOptions<T, TItemKey = string> = {
|
||||
props?: Partial<TanStackTableProps<T, TItemKey>>;
|
||||
export type RenderTanStackTableOptions<T> = {
|
||||
props?: Partial<TanStackTableProps<T>>;
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
};
|
||||
|
||||
export function renderTanStackTable<T = TestRow, TItemKey = string>(
|
||||
options: RenderTanStackTableOptions<T, TItemKey> = {},
|
||||
export function renderTanStackTable<T = TestRow>(
|
||||
options: RenderTanStackTableOptions<T> = {},
|
||||
): RenderResult {
|
||||
const { props = {}, queryParams, onUrlUpdate } = options;
|
||||
|
||||
@@ -57,7 +57,7 @@ export function renderTanStackTable<T = TestRow, TItemKey = string>(
|
||||
data: defaultData as unknown as T[],
|
||||
columns: defaultColumns as unknown as TableColumnDef<T>[],
|
||||
...props,
|
||||
} as TanStackTableProps<T, TItemKey>;
|
||||
} as TanStackTableProps<T>;
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||
@@ -65,7 +65,7 @@ export function renderTanStackTable<T = TestRow, TItemKey = string>(
|
||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<TanStackTable<T, TItemKey> {...mergedProps} />
|
||||
<TanStackTable<T> {...mergedProps} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
|
||||
@@ -123,22 +123,6 @@ export * from './useTableParams';
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Object itemKey — use generic `TItemKey` when selection needs compound keys.
|
||||
* ```tsx
|
||||
* type SelectionParams = { id: string; cluster: string; namespace: string };
|
||||
*
|
||||
* <TanStackTable<Row, SelectionParams>
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.uid}
|
||||
* getItemKey={(row) => ({ id: row.name, cluster: row.cluster, namespace: row.namespace })}
|
||||
* onRowClick={(row, itemKey) => {
|
||||
* // itemKey is typed as SelectionParams
|
||||
* setSelection(itemKey);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
|
||||
@@ -24,15 +24,17 @@ export type TableCellContext<TData, TValue> = {
|
||||
isExpanded: boolean;
|
||||
canExpand: boolean;
|
||||
toggleExpanded: () => void;
|
||||
/** Business/selection key for the row */
|
||||
itemKey: string;
|
||||
/** Group metadata when row is part of a grouped view */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RowKeyData<TItemKey = string> = {
|
||||
export type RowKeyData = {
|
||||
/** Final unique key (with duplicate suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Business/selection key */
|
||||
itemKey: TItemKey;
|
||||
itemKey: string;
|
||||
/** Group metadata */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
@@ -80,14 +82,14 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
export type TableRowContext<TData> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
@@ -95,7 +97,7 @@ export type TableRowContext<TData, TItemKey = string> = {
|
||||
groupMeta?: Record<string, string>,
|
||||
) => ReactNode;
|
||||
/** Get key data for a row by index */
|
||||
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined;
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined;
|
||||
colCount: number;
|
||||
isDarkMode?: boolean;
|
||||
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
|
||||
@@ -145,7 +147,7 @@ export type TanstackTableQueryParamsConfig = {
|
||||
expanded?: string;
|
||||
};
|
||||
|
||||
export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
export type TanStackTableProps<TData> = {
|
||||
data: TData[];
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
||||
@@ -170,7 +172,7 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
||||
getRowKey?: (row: TData) => string;
|
||||
/** Function to get the business/selection key. Defaults to getRowKey result. */
|
||||
getItemKey?: (row: TData) => TItemKey;
|
||||
getItemKey?: (row: TData) => string;
|
||||
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
|
||||
groupBy?: Array<{ key: string }>;
|
||||
/** Extract group metadata from a row. Required when groupBy is set. */
|
||||
@@ -179,9 +181,9 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export interface RowKeyDataItem<TItemKey = string> {
|
||||
export interface RowKeyDataItem {
|
||||
/** Final unique key for the row (with dedup suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Item key for tracking (may differ from finalKey) */
|
||||
itemKey: TItemKey;
|
||||
itemKey: string;
|
||||
/** Group metadata when grouped */
|
||||
groupMeta: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataOptions<TData, TItemKey = string> {
|
||||
export interface UseRowKeyDataOptions<TData> {
|
||||
data: TData[];
|
||||
isLoading: boolean;
|
||||
getRowKey?: (item: TData) => string;
|
||||
getItemKey?: (item: TData) => TItemKey;
|
||||
getItemKey?: (item: TData) => string;
|
||||
groupBy?: Array<{ key: string }>;
|
||||
getGroupKey?: (item: TData) => Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataResult<TItemKey = string> {
|
||||
export interface UseRowKeyDataResult {
|
||||
/** Array of key data for each row, undefined if getRowKey not provided or loading */
|
||||
rowKeyData: RowKeyDataItem<TItemKey>[] | undefined;
|
||||
getRowKeyData: (index: number) => RowKeyDataItem<TItemKey> | undefined;
|
||||
rowKeyData: RowKeyDataItem[] | undefined;
|
||||
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes unique row keys with duplicate handling and group prefixes.
|
||||
*/
|
||||
export function useRowKeyData<TData, TItemKey = string>({
|
||||
export function useRowKeyData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
}: UseRowKeyDataOptions<TData, TItemKey>): UseRowKeyDataResult<TItemKey> {
|
||||
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const rowKeyData = useMemo((): RowKeyDataItem<TItemKey>[] | undefined => {
|
||||
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
|
||||
if (!getRowKey || isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyCount = new Map<string, number>();
|
||||
|
||||
return data.map((item, index): RowKeyDataItem<TItemKey> => {
|
||||
return data.map((item, index): RowKeyDataItem => {
|
||||
const itemIdentifier = getRowKey(item);
|
||||
const itemKey = getItemKey?.(item) ?? (itemIdentifier as TItemKey);
|
||||
const itemKey = getItemKey?.(item) ?? itemIdentifier;
|
||||
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
|
||||
|
||||
// Build rowKey with group prefix when grouped
|
||||
|
||||
@@ -86,10 +86,10 @@ const buildAccessorFn = <TData>(
|
||||
};
|
||||
};
|
||||
|
||||
export function buildTanstackColumnDef<TData, TItemKey = string>(
|
||||
export function buildTanstackColumnDef<TData>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
isRowActive?: (row: TData) => boolean,
|
||||
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined,
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined,
|
||||
): ColumnDef<TData> {
|
||||
const isFixed = colDef.width?.fixed != null;
|
||||
const headerFn =
|
||||
@@ -140,6 +140,7 @@ export function buildTanstackColumnDef<TData, TItemKey = string>(
|
||||
toggleExpanded: (): void => {
|
||||
row.toggleExpanded();
|
||||
},
|
||||
itemKey: keyData?.itemKey ?? '',
|
||||
groupMeta: keyData?.groupMeta,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -57,7 +57,4 @@ export enum QueryParams {
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
matchType = 'matchType',
|
||||
compareOp = 'compareOp',
|
||||
evaluationWindowPreset = 'evaluationWindowPreset',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const DASHBOARD_CACHE_TIME = 30_000;
|
||||
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
|
||||
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
|
||||
|
||||
export const FIELD_API_CACHE_TIME = 60_000;
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { CreateAlertProvider, useCreateAlertState } from '../index';
|
||||
|
||||
// The provider only needs a query with a unit + empty builder for these assertions.
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: {
|
||||
unit: 'bytes',
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mutation = { mutate: jest.fn(), isLoading: false };
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useCreateRule: (): unknown => mutation,
|
||||
useTestRule: (): unknown => mutation,
|
||||
useUpdateRuleByID: (): unknown => mutation,
|
||||
}));
|
||||
|
||||
function Probe(): JSX.Element {
|
||||
const { thresholdState, evaluationWindow } = useCreateAlertState();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="match-type">{thresholdState.matchType}</span>
|
||||
<span data-testid="operator">{thresholdState.operator}</span>
|
||||
<span data-testid="threshold-count">{thresholdState.thresholds.length}</span>
|
||||
<span data-testid="threshold-value">
|
||||
{thresholdState.thresholds[0]?.thresholdValue}
|
||||
</span>
|
||||
<span data-testid="window-type">{evaluationWindow.windowType}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithSearch(search: string): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function serializeThreshold(thresholdValue: number): string {
|
||||
return encodeURIComponent(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 't1',
|
||||
label: 'critical',
|
||||
thresholdValue,
|
||||
recoveryThresholdValue: null,
|
||||
unit: 'bytes',
|
||||
channels: [],
|
||||
color: '#F1575F',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
it('applies matchType, operator, and thresholds without the meter window', () => {
|
||||
// Dashboard-style prefill: no evaluationWindowPreset → default window.
|
||||
renderWithSearch(
|
||||
`?matchType=on_average&compareOp=below&thresholds=${serializeThreshold(90)}`,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('match-type')).toHaveTextContent('on_average');
|
||||
expect(screen.getByTestId('operator')).toHaveTextContent('below');
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('90');
|
||||
// The meter evaluation-window preset must NOT fire.
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('adjusts only the occurrence type when no thresholds are supplied', () => {
|
||||
renderWithSearch(`?matchType=in_total`);
|
||||
|
||||
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
|
||||
// The default single critical threshold and operator are retained.
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('0');
|
||||
expect(screen.getByTestId('operator')).toHaveTextContent('above');
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('applies the meter evaluation window when the preset is declared', () => {
|
||||
// Ingestion-style prefill: explicit in_total + meter preset.
|
||||
renderWithSearch(
|
||||
`?matchType=in_total&evaluationWindowPreset=meter&thresholds=${serializeThreshold(
|
||||
500,
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('500');
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { AlertThresholdMatchType, AlertThresholdOperator } from '../types';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
resolveUrlAlertPrefill,
|
||||
} from '../resolveUrlAlertPrefill';
|
||||
|
||||
function resolve(search: string): ReturnType<typeof resolveUrlAlertPrefill> {
|
||||
return resolveUrlAlertPrefill(new URLSearchParams(search));
|
||||
}
|
||||
|
||||
describe('resolveUrlAlertPrefill', () => {
|
||||
it('returns an empty plan when no params are present', () => {
|
||||
expect(resolve('')).toStrictEqual({
|
||||
thresholds: undefined,
|
||||
matchType: undefined,
|
||||
operator: undefined,
|
||||
evaluationWindowPreset: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a thresholds array', () => {
|
||||
const thresholds = [{ id: 't1', thresholdValue: 90 }];
|
||||
const { thresholds: parsed } = resolve(
|
||||
`thresholds=${encodeURIComponent(JSON.stringify(thresholds))}`,
|
||||
);
|
||||
expect(parsed).toStrictEqual(thresholds);
|
||||
});
|
||||
|
||||
it('ignores malformed or non-array thresholds without throwing', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(resolve('thresholds=not-json').thresholds).toBeUndefined();
|
||||
expect(
|
||||
resolve(`thresholds=${encodeURIComponent('{"a":1}')}`).thresholds,
|
||||
).toBeUndefined();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('normalizes matchType aliases', () => {
|
||||
expect(resolve('matchType=on_average').matchType).toBe(
|
||||
AlertThresholdMatchType.ON_AVERAGE,
|
||||
);
|
||||
expect(resolve('matchType=sum').matchType).toBe(
|
||||
AlertThresholdMatchType.IN_TOTAL,
|
||||
);
|
||||
expect(resolve('matchType=nonsense').matchType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes operator aliases', () => {
|
||||
expect(resolve('compareOp=above').operator).toBe(
|
||||
AlertThresholdOperator.IS_ABOVE,
|
||||
);
|
||||
expect(resolve('compareOp=%3E').operator).toBe(
|
||||
AlertThresholdOperator.IS_ABOVE,
|
||||
);
|
||||
expect(resolve('compareOp=nonsense').operator).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognizes the meter evaluation-window preset only', () => {
|
||||
expect(resolve('evaluationWindowPreset=meter').evaluationWindowPreset).toBe(
|
||||
EvaluationWindowPreset.METER,
|
||||
);
|
||||
expect(
|
||||
resolve('evaluationWindowPreset=rolling').evaluationWindowPreset,
|
||||
).toBeUndefined();
|
||||
expect(resolve('').evaluationWindowPreset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
|
||||
|
||||
// Mirrors the backend's CompareOperator.Normalize() in
|
||||
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
|
||||
// the dropdown understands. Returns undefined for aliases the UI does not
|
||||
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
|
||||
// value on screen instead of silently rewriting it.
|
||||
export function normalizeOperator(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'above':
|
||||
case '>':
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case '2':
|
||||
case 'below':
|
||||
case '<':
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case '3':
|
||||
case 'equal':
|
||||
case 'eq':
|
||||
case '=':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case '4':
|
||||
case 'not_equal':
|
||||
case 'not_eq':
|
||||
case '!=':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
case '7':
|
||||
case 'outside_bounds':
|
||||
return AlertThresholdOperator.ABOVE_BELOW;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
|
||||
export function normalizeMatchType(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdMatchType | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'at_least_once':
|
||||
return AlertThresholdMatchType.AT_LEAST_ONCE;
|
||||
case '2':
|
||||
case 'all_the_times':
|
||||
return AlertThresholdMatchType.ALL_THE_TIME;
|
||||
case '3':
|
||||
case 'on_average':
|
||||
case 'avg':
|
||||
return AlertThresholdMatchType.ON_AVERAGE;
|
||||
case '4':
|
||||
case 'in_total':
|
||||
case 'sum':
|
||||
return AlertThresholdMatchType.IN_TOTAL;
|
||||
case '5':
|
||||
case 'last':
|
||||
return AlertThresholdMatchType.LAST;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,10 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from './constants';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
resolveUrlAlertPrefill,
|
||||
} from './resolveUrlAlertPrefill';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdMatchType,
|
||||
CreateAlertAction,
|
||||
CreateAlertSlice,
|
||||
EvaluationWindowAction,
|
||||
@@ -126,13 +123,9 @@ export function CreateAlertProvider(
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
|
||||
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
|
||||
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
|
||||
// Prefill declared in the URL; applied verbatim, agnostic of the producer.
|
||||
const urlPrefill = useMemo(
|
||||
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
|
||||
[location.search],
|
||||
);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
if (isEditMode) {
|
||||
@@ -175,43 +168,34 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
|
||||
if (urlPrefill.thresholds) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: urlPrefill.thresholds,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (thresholdsFromURL) {
|
||||
try {
|
||||
const thresholds = JSON.parse(thresholdsFromURL);
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
}
|
||||
|
||||
if (urlPrefill.matchType) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: urlPrefill.matchType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.operator) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_OPERATOR',
|
||||
payload: urlPrefill.operator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
action: {
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
},
|
||||
});
|
||||
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (ruleNameFromURL && !ruleNameAppliedRef.current) {
|
||||
@@ -235,7 +219,7 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL]);
|
||||
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
import { normalizeMatchType, normalizeOperator } from './conditionNormalizers';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
Threshold,
|
||||
} from './types';
|
||||
|
||||
export enum EvaluationWindowPreset {
|
||||
METER = 'meter',
|
||||
}
|
||||
|
||||
export interface ResolvedAlertPrefill {
|
||||
thresholds?: Threshold[];
|
||||
matchType?: AlertThresholdMatchType;
|
||||
operator?: AlertThresholdOperator;
|
||||
evaluationWindowPreset?: EvaluationWindowPreset;
|
||||
}
|
||||
|
||||
function parseThresholds(raw: string | null): Threshold[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Threshold[]) : undefined;
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvaluationWindowPreset(
|
||||
raw: string | null,
|
||||
): EvaluationWindowPreset | undefined {
|
||||
return raw === EvaluationWindowPreset.METER
|
||||
? EvaluationWindowPreset.METER
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** URL → declarative prefill plan; the consumer applies it without knowing the producer. */
|
||||
export function resolveUrlAlertPrefill(
|
||||
params: URLSearchParams,
|
||||
): ResolvedAlertPrefill {
|
||||
return {
|
||||
thresholds: parseThresholds(params.get(QueryParams.thresholds)),
|
||||
matchType: normalizeMatchType(params.get(QueryParams.matchType) ?? undefined),
|
||||
operator: normalizeOperator(params.get(QueryParams.compareOp) ?? undefined),
|
||||
evaluationWindowPreset: parseEvaluationWindowPreset(
|
||||
params.get(QueryParams.evaluationWindowPreset),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -238,12 +238,67 @@ export function getAdvancedOptionsStateFromAlertDef(
|
||||
};
|
||||
}
|
||||
|
||||
// Condition-alias normalizers live in a leaf module (they depend only on the
|
||||
// enums) so `context/index` can consume them without an index↔utils cycle.
|
||||
export {
|
||||
normalizeOperator,
|
||||
normalizeMatchType,
|
||||
} from './context/conditionNormalizers';
|
||||
// Mirrors the backend's CompareOperator.Normalize() in
|
||||
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
|
||||
// the dropdown understands. Returns undefined for aliases the UI does not
|
||||
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
|
||||
// value on screen instead of silently rewriting it.
|
||||
export function normalizeOperator(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'above':
|
||||
case '>':
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case '2':
|
||||
case 'below':
|
||||
case '<':
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case '3':
|
||||
case 'equal':
|
||||
case 'eq':
|
||||
case '=':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case '4':
|
||||
case 'not_equal':
|
||||
case 'not_eq':
|
||||
case '!=':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
case '7':
|
||||
case 'outside_bounds':
|
||||
return AlertThresholdOperator.ABOVE_BELOW;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
|
||||
export function normalizeMatchType(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdMatchType | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'at_least_once':
|
||||
return AlertThresholdMatchType.AT_LEAST_ONCE;
|
||||
case '2':
|
||||
case 'all_the_times':
|
||||
return AlertThresholdMatchType.ALL_THE_TIME;
|
||||
case '3':
|
||||
case 'on_average':
|
||||
case 'avg':
|
||||
return AlertThresholdMatchType.ON_AVERAGE;
|
||||
case '4':
|
||||
case 'in_total':
|
||||
case 'sum':
|
||||
return AlertThresholdMatchType.IN_TOTAL;
|
||||
case '5':
|
||||
case 'last':
|
||||
return AlertThresholdMatchType.LAST;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
@@ -67,6 +66,7 @@ import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMapp
|
||||
import { cloneDeep, isEqual, omit } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { FormattingOptions } from 'providers/preferences/types';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -1031,19 +1031,26 @@ function ExplorerOptions({
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<ExportPanelContainer
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
open={isExport}
|
||||
onClose={onCancel(false)}
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1051,7 +1058,7 @@ function ExplorerOptions({
|
||||
export interface ExplorerOptionsProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (
|
||||
dashboard: ExportDashboard | null,
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -81,7 +80,7 @@ const renderExplorerOptionWrapper = (
|
||||
isLoading: false,
|
||||
onExport: jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: ExportDashboard | null,
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
@@ -151,7 +150,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: ExportDashboard | null,
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
@@ -180,16 +179,15 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New dashboard" button
|
||||
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(
|
||||
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
|
||||
true,
|
||||
);
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,7 +195,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: ExportDashboard | null,
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
@@ -231,12 +229,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
await waitFor(() => {
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -252,21 +251,19 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(
|
||||
{ id: 'dashboard-1', title: 'Dashboard 1' },
|
||||
false,
|
||||
);
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,7 +284,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
|
||||
// Create a real handleExport function similar to LogsExplorerViews
|
||||
// This should NOT call useUpdateDashboard (as per PR #8029)
|
||||
const handleExport = (dashboard: ExportDashboard | null): void => {
|
||||
const handleExport = (dashboard: Dashboard | null): void => {
|
||||
if (!dashboard) {
|
||||
return;
|
||||
}
|
||||
@@ -329,12 +326,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
await waitFor(() => {
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -350,13 +348,14 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
@@ -376,7 +375,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
it('should not show export buttons when component is disabled', () => {
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: ExportDashboard | null,
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
|
||||
import { getSelectOptions } from './utils';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
interface ExportDashboardSelectProps {
|
||||
dashboards: ExportDashboard[];
|
||||
value: string | null;
|
||||
/** The picked dashboard, pinned as an option so its label survives a later search. */
|
||||
selectedDashboard: ExportDashboard | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (dashboardId: string) => void;
|
||||
onSearch: (search: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
|
||||
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
|
||||
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
|
||||
* dropdown.
|
||||
*/
|
||||
function ExportDashboardSelect({
|
||||
dashboards,
|
||||
value,
|
||||
selectedDashboard,
|
||||
loading,
|
||||
disabled,
|
||||
onChange,
|
||||
onSearch,
|
||||
}: ExportDashboardSelectProps): JSX.Element {
|
||||
const options = useMemo<SelectProps['options']>(() => {
|
||||
const base = getSelectOptions(dashboards) ?? [];
|
||||
if (
|
||||
selectedDashboard &&
|
||||
!base.some((option) => option.value === selectedDashboard.id)
|
||||
) {
|
||||
return [
|
||||
{ label: selectedDashboard.title, value: selectedDashboard.id },
|
||||
...base,
|
||||
];
|
||||
}
|
||||
return base;
|
||||
}, [dashboards, selectedDashboard]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={styles.dashboardSelect}
|
||||
placeholder="Select a dashboard"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
data-testid="export-dashboard-select"
|
||||
options={options}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ExportDashboardSelect.defaultProps = {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ExportDashboardSelect;
|
||||
@@ -1,42 +0,0 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.dashboardSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.newDashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,159 +1,127 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
|
||||
import createDashboard from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { ExportPanelProps } from '.';
|
||||
import {
|
||||
ExportDashboard,
|
||||
useExportDashboards,
|
||||
} from 'hooks/dashboard/useExportDashboards';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
DashboardSelect,
|
||||
NewDashboardButton,
|
||||
SelectWrapper,
|
||||
Title,
|
||||
Wrapper,
|
||||
} from './styles';
|
||||
import { filterOptions, getSelectOptions } from './utils';
|
||||
|
||||
import ExportDashboardSelect from './ExportDashboardSelect';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
) => void;
|
||||
query: Query | null;
|
||||
/** Controlled open state of the dialog. */
|
||||
open: boolean;
|
||||
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
|
||||
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
|
||||
*/
|
||||
function ExportPanelContainer({
|
||||
isLoading,
|
||||
onExport,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
// Track the object, not just the id, so export survives a search that narrows it out.
|
||||
const [selectedDashboard, setSelectedDashboard] =
|
||||
useState<ExportDashboard | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
dashboards,
|
||||
data,
|
||||
isLoading: isAllDashboardsLoading,
|
||||
isFetching: isDashboardsFetching,
|
||||
} = useExportDashboards(searchText);
|
||||
refetch,
|
||||
} = useGetAllDashboard();
|
||||
|
||||
const { create: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useCreateExportDashboard({
|
||||
title: t('new_dashboard_title', { ns: 'dashboard' }),
|
||||
onCreated: (dashboard) => onExport(dashboard, true),
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useMutation(createDashboard, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onExport(data?.data, true);
|
||||
}
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(error as APIError);
|
||||
},
|
||||
});
|
||||
|
||||
// Reset on close so each open starts fresh (the dialog stays mounted).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedDashboard(null);
|
||||
setSearchText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(dashboardId: string): void => {
|
||||
setSelectedDashboard(
|
||||
dashboards.find(({ id }) => id === dashboardId) ?? null,
|
||||
);
|
||||
},
|
||||
[dashboards],
|
||||
);
|
||||
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
|
||||
|
||||
const handleExportClick = useCallback((): void => {
|
||||
onExport(selectedDashboard, false);
|
||||
}, [selectedDashboard, onExport]);
|
||||
const currentSelectedDashboard = data?.data?.find(
|
||||
({ id }) => id === dashboardId,
|
||||
);
|
||||
|
||||
const isExportDisabled =
|
||||
isAllDashboardsLoading || !selectedDashboard || isLoading;
|
||||
onExport(currentSelectedDashboard || null, false);
|
||||
}, [data, dashboardId, onExport]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDashboardId: string): void => {
|
||||
setDashboardId(selectedDashboardId);
|
||||
},
|
||||
[setDashboardId],
|
||||
);
|
||||
|
||||
const handleNewDashboard = useCallback(async () => {
|
||||
try {
|
||||
await createNewDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [createNewDashboard, t, showErrorModal]);
|
||||
|
||||
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
|
||||
|
||||
const isDisabled =
|
||||
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Add to dashboard"
|
||||
testId="export-panel-dialog"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="export-panel-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isExportDisabled}
|
||||
onClick={handleExportClick}
|
||||
testId="export-panel-export"
|
||||
>
|
||||
Add to dashboard
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Select a dashboard
|
||||
</Typography.Text>
|
||||
<ExportDashboardSelect
|
||||
dashboards={dashboards}
|
||||
value={selectedDashboard?.id ?? null}
|
||||
selectedDashboard={selectedDashboard}
|
||||
loading={isDashboardsFetching}
|
||||
disabled={isAllDashboardsLoading || createDashboardLoading}
|
||||
onChange={handleSelect}
|
||||
onSearch={setSearchText}
|
||||
/>
|
||||
</div>
|
||||
<Wrapper direction="vertical">
|
||||
<Title>Export Panel</Title>
|
||||
|
||||
<div className={styles.newDashboard}>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Or create a new dashboard with this panel
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
loading={createDashboardLoading}
|
||||
disabled={createDashboardLoading}
|
||||
onClick={createNewDashboard}
|
||||
testId="export-panel-new-dashboard"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
<SelectWrapper direction="horizontal">
|
||||
<DashboardSelect
|
||||
placeholder="Select Dashboard"
|
||||
options={options}
|
||||
showSearch
|
||||
loading={isDashboardLoading}
|
||||
disabled={isDashboardLoading}
|
||||
value={dashboardId}
|
||||
onSelect={handleSelect}
|
||||
filterOption={filterOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
onClick={handleExportClick}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SelectWrapper>
|
||||
|
||||
<Typography>
|
||||
Or create dashboard with this panel -
|
||||
<NewDashboardButton
|
||||
disabled={createDashboardLoading}
|
||||
loading={createDashboardLoading}
|
||||
type="link"
|
||||
onClick={handleNewDashboard}
|
||||
>
|
||||
New Dashboard
|
||||
</NewDashboardButton>
|
||||
</Typography>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ExportPanelContainer.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export default ExportPanelContainer;
|
||||
|
||||
49
frontend/src/container/ExportPanel/index.tsx
Normal file
49
frontend/src/container/ExportPanel/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ExportPanelContainer from './ExportPanelContainer';
|
||||
|
||||
function ExportPanel({
|
||||
isLoading,
|
||||
onExport,
|
||||
query,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const [isExport, setIsExport] = useState<boolean>(false);
|
||||
|
||||
const onModalToggle = useCallback((value: boolean) => {
|
||||
setIsExport(value);
|
||||
}, []);
|
||||
|
||||
const onCancel = (value: boolean) => (): void => {
|
||||
onModalToggle(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={query}
|
||||
isLoading={isLoading}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
}
|
||||
|
||||
ExportPanel.defaultProps = { isLoading: false };
|
||||
|
||||
export default ExportPanel;
|
||||
34
frontend/src/container/ExportPanel/styles.ts
Normal file
34
frontend/src/container/ExportPanel/styles.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Button, Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
|
||||
Select,
|
||||
)<SelectProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SelectWrapper = styled(Space)`
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ant-space-item:first-child {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const NewDashboardButton = styled(Button)`
|
||||
&&& {
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Title = styled(Typography.Text)`
|
||||
font-size: 1rem;
|
||||
`;
|
||||
@@ -1,10 +1,16 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
export const getSelectOptions = (
|
||||
data: ExportDashboard[],
|
||||
): SelectProps['options'] =>
|
||||
data.map(({ id, title }) => ({
|
||||
label: title,
|
||||
export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
|
||||
data.map(({ id, data }) => ({
|
||||
label: data.title,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
export const filterOptions: SelectProps['filterOption'] = (
|
||||
input,
|
||||
options,
|
||||
): boolean =>
|
||||
(options?.label?.toString() ?? '')
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase());
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listHosts } from 'api/generated/services/inframonitoring';
|
||||
import {
|
||||
InframonitoringtypesHostRecordDTO,
|
||||
InframonitoringtypesHostStatusDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -23,10 +19,7 @@ import K8sBaseDetails, {
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
|
||||
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -50,7 +43,6 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
@@ -59,17 +51,6 @@ function Hosts(): JSX.Element {
|
||||
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
|
||||
const { startUnixMilli, endUnixMilli } = useMemo(() => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
return {
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTime, getMinMaxTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
return;
|
||||
@@ -141,12 +122,13 @@ function Hosts(): JSX.Element {
|
||||
endTimeBeforeRetention: data.endTimeBeforeRetention,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch hosts';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesHostRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -159,7 +141,7 @@ function Hosts(): JSX.Element {
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesHostRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listHosts(
|
||||
@@ -177,10 +159,11 @@ function Hosts(): JSX.Element {
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch host';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -225,12 +208,6 @@ function Hosts(): JSX.Element {
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={{
|
||||
metricNamespace:
|
||||
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
hostWidgetInfo,
|
||||
@@ -108,10 +107,8 @@ export function getHostMetricsQueryPayload(
|
||||
|
||||
export { hostWidgetInfo };
|
||||
|
||||
export const hostGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
): string =>
|
||||
`host.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
|
||||
export const hostGetSelectedItemExpression = (hostName: string): string =>
|
||||
`host.name = ${formatValueForExpression(hostName)}`;
|
||||
|
||||
export function hostInitialLogTracesExpression(
|
||||
host: InframonitoringtypesHostRecordDTO,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/const
|
||||
import { CellValueTooltip } from 'container/InfraMonitoringK8sV2/components';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
const HOSTNAME_DOCS_URL =
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
|
||||
|
||||
@@ -21,7 +23,11 @@ export function HostnameCell({
|
||||
}): React.ReactElement {
|
||||
const isEmpty = !hostName || !hostName.trim();
|
||||
if (!isEmpty) {
|
||||
return <CellValueTooltip value={hostName} />;
|
||||
return (
|
||||
<CellValueTooltip value={hostName}>
|
||||
<TanStackTable.Text>{hostName}</TanStackTable.Text>
|
||||
</CellValueTooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
}
|
||||
|
||||
.columnHeaderLabel {
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
padding: var(--spacing-2) var(--spacing-2) var(--spacing-2) 0px;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function ColumnHeader({
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
|
||||
@@ -30,7 +30,7 @@ function EntityGroupHeader({
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
|
||||
@@ -12,8 +12,6 @@ import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -54,10 +52,9 @@ import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringSelectedItem,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
@@ -65,10 +62,6 @@ import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import '../EntityDetailsUtils/entityDetails.styles.scss';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
EntityCountConfig,
|
||||
EntityCountsSection,
|
||||
} from './components/EntityCountsSection/EntityCountsSection';
|
||||
|
||||
const TimeRangeOffset = 1000000000;
|
||||
|
||||
@@ -78,51 +71,29 @@ export interface K8sDetailsMetadataConfig<T> {
|
||||
render?: (value: string | number, entity: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface CustomTabRenderProps<T> {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CustomTab<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
getSelectedItemExpression: (selectedItem: string) => string;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
) => Promise<{ data: T | null; error?: string | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
entity: T,
|
||||
@@ -139,7 +110,20 @@ export interface K8sBaseDetailsProps<T> {
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<CustomTab<T>>;
|
||||
customTabs?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}) => React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -152,8 +136,6 @@ export default function K8sBaseDetails<T>({
|
||||
getInitialLogTracesExpression,
|
||||
getInitialEventsExpression,
|
||||
metadataConfig,
|
||||
countsConfig,
|
||||
getCountsFilterExpression,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKeyPrefix,
|
||||
@@ -170,9 +152,7 @@ export default function K8sBaseDetails<T>({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
|
||||
const entityQueryKey = useMemo(
|
||||
() =>
|
||||
@@ -180,17 +160,8 @@ export default function K8sBaseDetails<T>({
|
||||
selectedTime,
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
),
|
||||
[
|
||||
queryKeyPrefix,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedTime,
|
||||
getAutoRefreshQueryKey,
|
||||
],
|
||||
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -207,7 +178,7 @@ 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);
|
||||
const expression = getSelectedItemExpression(selectedItem);
|
||||
|
||||
return fetchEntityData({ filter: { expression }, start, end }, signal);
|
||||
},
|
||||
@@ -232,8 +203,8 @@ export default function K8sBaseDetails<T>({
|
||||
}, [entity, getInitialEventsExpression]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setSelectedItemParams(null);
|
||||
}, [setSelectedItemParams]);
|
||||
setSelectedItem(null);
|
||||
}, [setSelectedItem]);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
@@ -275,33 +246,6 @@ export default function K8sBaseDetails<T>({
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
if (tabVisibility.showLogs) {
|
||||
tabs.push(VIEW_TYPES.LOGS);
|
||||
}
|
||||
if (tabVisibility.showTraces) {
|
||||
tabs.push(VIEW_TYPES.TRACES);
|
||||
}
|
||||
if (tabVisibility.showEvents) {
|
||||
tabs.push(VIEW_TYPES.EVENTS);
|
||||
}
|
||||
if (customTabs) {
|
||||
tabs.push(...customTabs.map((t) => t.key));
|
||||
}
|
||||
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 [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
@@ -337,10 +281,7 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
|
||||
const handleTabChange = (value: string | null): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const handleTabChange = (value: string): void => {
|
||||
setSelectedView(value);
|
||||
setLogFiltersParam(null);
|
||||
setTracesFiltersParam(null);
|
||||
@@ -483,18 +424,12 @@ export default function K8sBaseDetails<T>({
|
||||
{isEntityLoading && <LoadingContainer />}
|
||||
{(isEntityError || hasResponseError) && (
|
||||
<div className="entity-error-container">
|
||||
<ErrorContent
|
||||
error={
|
||||
entityResponse?.error ??
|
||||
(entityError instanceof APIError ? entityError : null) ?? {
|
||||
code: 500,
|
||||
message:
|
||||
entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details',
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Typography.Text color="danger">
|
||||
{entityResponse?.error ||
|
||||
(entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{entity && !isEntityLoading && !hasResponseError && (
|
||||
@@ -532,19 +467,6 @@ export default function K8sBaseDetails<T>({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
|
||||
@@ -10,21 +10,19 @@ import TanStackTable, {
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringStatusFilter,
|
||||
} from '../hooks';
|
||||
import { useInfraMonitoringLineClamp } from '../components';
|
||||
@@ -34,14 +32,13 @@ import K8sHeader from './K8sHeader';
|
||||
import { K8sPaginationWarning } from './K8sPaginationWarning';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
|
||||
|
||||
import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
export type K8sBaseListEmptyStateContext = {
|
||||
isError: boolean;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
totalCount: number;
|
||||
hasFilters: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -52,10 +49,7 @@ export type K8sBaseListEmptyStateContext = {
|
||||
/** Base type constraint for K8s entity data */
|
||||
export type K8sEntityData = { meta?: Record<string, string> | null };
|
||||
|
||||
export type K8sBaseListProps<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
> = {
|
||||
export type K8sBaseListProps<T extends K8sEntityData> = {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
leftFilters?: React.ReactNode;
|
||||
entity: InfraMonitoringEntity;
|
||||
@@ -68,15 +62,15 @@ export type K8sBaseListProps<
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total: number;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
|
||||
}>;
|
||||
/** Function to get the unique key for a row. */
|
||||
getRowKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
|
||||
getItemKey?: (record: T) => TItemKey;
|
||||
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
|
||||
getItemKey?: (record: T) => string;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
renderEmptyState?: (
|
||||
context: K8sBaseListEmptyStateContext,
|
||||
@@ -84,10 +78,7 @@ export type K8sBaseListProps<
|
||||
extraQueryKeyParts?: string[];
|
||||
};
|
||||
|
||||
export function K8sBaseList<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
>({
|
||||
export function K8sBaseList<T extends K8sEntityData>({
|
||||
controlListPrefix,
|
||||
leftFilters,
|
||||
entity,
|
||||
@@ -98,16 +89,17 @@ export function K8sBaseList<
|
||||
eventCategory,
|
||||
renderEmptyState,
|
||||
extraQueryKeyParts = [],
|
||||
}: K8sBaseListProps<T, TItemKey>): JSX.Element {
|
||||
}: K8sBaseListProps<T>): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const expression = currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const lineClamp = useInfraMonitoringLineClamp();
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy] = useInfraMonitoringOrderBy();
|
||||
const [statusFilter] = useInfraMonitoringStatusFilter();
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
const [selectedItem, setSelectedItem] = useQueryState(
|
||||
'selectedItem',
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const columnStorageKey = `k8s-${entity}-columns`;
|
||||
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
|
||||
@@ -234,17 +226,9 @@ export function K8sBaseList<
|
||||
}, [eventCategory, totalCount]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(_record: T, itemKey: TItemKey): void => {
|
||||
(_record: T, itemKey: string): void => {
|
||||
if (groupBy.length === 0) {
|
||||
if (typeof itemKey === 'object' && itemKey !== null) {
|
||||
setSelectedItemParams(itemKey);
|
||||
} else {
|
||||
setSelectedItemParams({
|
||||
selectedItem: itemKey,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
}
|
||||
void setSelectedItem(itemKey);
|
||||
}
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
@@ -253,43 +237,18 @@ export function K8sBaseList<
|
||||
category: eventCategory,
|
||||
});
|
||||
},
|
||||
[eventCategory, groupBy.length, setSelectedItemParams],
|
||||
[eventCategory, groupBy.length, setSelectedItem],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(_record: T, itemKey: TItemKey): void => {
|
||||
(_record: T, itemKey: string): void => {
|
||||
if (groupBy.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build URL with selectedItem params
|
||||
// Build URL with selectedItem param
|
||||
const url = new URL(window.location.href);
|
||||
if (typeof itemKey === 'object' && itemKey !== null) {
|
||||
const params = itemKey;
|
||||
if (params.selectedItem) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
params.selectedItem,
|
||||
);
|
||||
}
|
||||
if (params.clusterName) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME,
|
||||
params.clusterName,
|
||||
);
|
||||
}
|
||||
if (params.namespaceName) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME,
|
||||
params.namespaceName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
itemKey,
|
||||
);
|
||||
}
|
||||
url.searchParams.set('selectedItem', itemKey);
|
||||
openInNewTab(url.pathname + url.search);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
@@ -315,7 +274,7 @@ export function K8sBaseList<
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
): JSX.Element => (
|
||||
<K8sExpandedRow<T, TItemKey>
|
||||
<K8sExpandedRow<T>
|
||||
rowKey={rowKey}
|
||||
groupMeta={groupMeta}
|
||||
entity={entity}
|
||||
@@ -379,8 +338,6 @@ export function K8sBaseList<
|
||||
cancelQuery={cancelQuery}
|
||||
/>
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
{isError && (
|
||||
<Typography>
|
||||
{data?.error?.toString() || 'Something went wrong'}
|
||||
@@ -390,7 +347,7 @@ export function K8sBaseList<
|
||||
{showEmptyState ? (
|
||||
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
|
||||
) : (
|
||||
<TanStackTable<T, TItemKey>
|
||||
<TanStackTable<T>
|
||||
data={pageData}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={columnStorageKey}
|
||||
|
||||
@@ -44,11 +44,13 @@
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
max-width: 500px;
|
||||
padding: 24px;
|
||||
.errorIcon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { LifeBuoy, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
|
||||
@@ -9,12 +13,26 @@ import styles from './K8sEmptyState.module.scss';
|
||||
|
||||
type K8sEmptyStateProps = Partial<K8sBaseListEmptyStateContext>;
|
||||
|
||||
const handleContactSupport = (isCloudUser: boolean): void => {
|
||||
if (isCloudUser) {
|
||||
history.push('/support');
|
||||
} else {
|
||||
window.open('https://signoz.io/slack', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
export function K8sEmptyState({
|
||||
isError,
|
||||
error,
|
||||
isLoading,
|
||||
endTimeBeforeRetention,
|
||||
}: K8sEmptyStateProps): JSX.Element | null {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const handleSupport = useCallback(() => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -22,15 +40,25 @@ export function K8sEmptyState({
|
||||
if (isError || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.errorContent}>
|
||||
<ErrorContent
|
||||
error={
|
||||
error ?? {
|
||||
code: 500,
|
||||
message: 'An error occurred while fetching data.',
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<TriangleAlert size={32} className={styles.errorIcon} />
|
||||
<span className={styles.message}>
|
||||
{error || 'An error occurred while fetching data.'}
|
||||
</span>
|
||||
<p>
|
||||
Our team is getting on top to resolve this. Please reach out to support if
|
||||
the issue persists.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
onClick={handleSupport}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import APIError from 'types/api/error';
|
||||
import TanStackTable, {
|
||||
SortState,
|
||||
TableColumnDef,
|
||||
@@ -23,11 +22,10 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringSelectedItem,
|
||||
} from '../hooks';
|
||||
import { K8sBaseFilters } from './types';
|
||||
|
||||
@@ -36,7 +34,7 @@ import { buildExpressionFromGroupMeta } from './utils';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
export type K8sExpandedRowProps<T, TItemKey = string> = {
|
||||
export type K8sExpandedRowProps<T> = {
|
||||
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
|
||||
rowKey: string;
|
||||
/** Group metadata for building filters */
|
||||
@@ -52,7 +50,7 @@ export type K8sExpandedRowProps<T, TItemKey = string> = {
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total: number;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
|
||||
}>;
|
||||
@@ -61,10 +59,10 @@ export type K8sExpandedRowProps<T, TItemKey = string> = {
|
||||
/** Function to get the unique key for a row. */
|
||||
getRowKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
|
||||
getItemKey?: (record: T) => TItemKey;
|
||||
getItemKey?: (record: T) => string;
|
||||
};
|
||||
|
||||
export function K8sExpandedRow<T, TItemKey = string>({
|
||||
export function K8sExpandedRow<T>({
|
||||
rowKey,
|
||||
groupMeta,
|
||||
entity,
|
||||
@@ -73,13 +71,13 @@ export function K8sExpandedRow<T, TItemKey = string>({
|
||||
extraQueryKeyParts = [],
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
|
||||
}: K8sExpandedRowProps<T>): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const parentExpression =
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -177,18 +175,10 @@ export function K8sExpandedRow<T, TItemKey = string>({
|
||||
const expandedData = data?.data ?? [];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(_row: T, itemKey: TItemKey): void => {
|
||||
if (typeof itemKey === 'object' && itemKey !== null) {
|
||||
setSelectedItemParams(itemKey as unknown as SelectedItemParams);
|
||||
} else {
|
||||
setSelectedItemParams({
|
||||
selectedItem: itemKey as string,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
}
|
||||
(_row: T, itemKey: string): void => {
|
||||
void setSelectedItem(itemKey);
|
||||
},
|
||||
[setSelectedItemParams],
|
||||
[setSelectedItem],
|
||||
);
|
||||
|
||||
const handleViewAllClick = (): void => {
|
||||
@@ -249,7 +239,7 @@ export function K8sExpandedRow<T, TItemKey = string>({
|
||||
|
||||
<div data-testid="expanded-table">
|
||||
<TanStackTableStateProvider>
|
||||
<TanStackTable<T, TItemKey>
|
||||
<TanStackTable<T>
|
||||
data={expandedData}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={storageKey}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { Box } from '@signozhq/icons';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
VIEW_TYPES,
|
||||
} from '../../constants';
|
||||
import K8sBaseDetails from '../K8sBaseDetails';
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
type TestEntity = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
cluster: string;
|
||||
};
|
||||
|
||||
const mockEntity: TestEntity = {
|
||||
name: 'test-pod',
|
||||
namespace: 'default',
|
||||
cluster: 'test-cluster',
|
||||
};
|
||||
|
||||
function createBaseProps() {
|
||||
return {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventCategory: 'Pod',
|
||||
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
fetchEntityData: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null }),
|
||||
getEntityName: (e: TestEntity): string => e.name,
|
||||
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
metadataConfig: [
|
||||
{ label: 'Name', getValue: (e: TestEntity): string => e.name },
|
||||
],
|
||||
entityWidgetInfo: [{ title: 'CPU', yAxisUnit: 'percent' }],
|
||||
getEntityQueryPayload: jest.fn().mockReturnValue([]),
|
||||
queryKeyPrefix: 'testPod',
|
||||
};
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
view?: string;
|
||||
tabsConfig?: {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: () => React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
function renderK8sBaseDetails({
|
||||
view = VIEW_TYPES.METRICS,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
}: RenderOptions = {}) {
|
||||
const searchParams: Record<string, string> = {
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'test-pod',
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
};
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
{...createBaseProps()}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectedTabText(): string | null {
|
||||
const selectedTab = document.querySelector('[aria-checked="true"]');
|
||||
return selectedTab?.textContent ?? null;
|
||||
}
|
||||
|
||||
describe('K8sBaseDetails - Tab Validation', () => {
|
||||
it('should reset view to METRICS when selected view is invalid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({ view: 'invalid-tab' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: 'invalid-tab',
|
||||
tabsConfig: { showMetrics: false },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to custom tab when all standard tabs disabled and custom tab exists', async () => {
|
||||
const customTabKey = 'pod-metrics';
|
||||
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: 'invalid-tab',
|
||||
tabsConfig: {
|
||||
showMetrics: false,
|
||||
showLogs: false,
|
||||
showTraces: false,
|
||||
showEvents: false,
|
||||
},
|
||||
customTabs: [
|
||||
{
|
||||
key: customTabKey,
|
||||
label: 'Pod Metrics',
|
||||
icon: <Box size={14} />,
|
||||
render: (): React.ReactNode => <div>Custom Tab</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Pod Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT reset view when selected view is valid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({ view: VIEW_TYPES.LOGS });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT reset view when custom tab is selected and exists', async () => {
|
||||
const customTabKey = 'pod-metrics';
|
||||
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: customTabKey,
|
||||
customTabs: [
|
||||
{
|
||||
key: customTabKey,
|
||||
label: 'Pod Metrics',
|
||||
icon: <Box size={14} />,
|
||||
render: (): React.ReactNode => <div>Custom Tab</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Pod Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the selected tab when the active tab is clicked again (untoggle guard)', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
act(() => {
|
||||
renderK8sBaseDetails({ view: VIEW_TYPES.LOGS });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
|
||||
const selectedTab = document.querySelector('[aria-checked="true"]');
|
||||
expect(selectedTab).not.toBeNull();
|
||||
|
||||
await user.click(selectedTab as Element);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
@@ -19,13 +17,11 @@ import {
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import { InfraMonitoringEntity } from '../../constants';
|
||||
import { SelectedItemParams } from '../../hooks';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
@@ -169,14 +165,11 @@ function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function renderComponent<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
>({
|
||||
function renderComponent<T extends K8sEntityData>({
|
||||
queryParams,
|
||||
onUrlUpdate,
|
||||
...props
|
||||
}: K8sBaseListProps<T, TItemKey> & {
|
||||
}: K8sBaseListProps<T> & {
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
}) {
|
||||
@@ -203,7 +196,7 @@ function renderComponent<
|
||||
value={{ viewportHeight: 800, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<K8sBaseList<T, TItemKey> {...props} />
|
||||
<K8sBaseList {...props} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>
|
||||
@@ -633,15 +626,7 @@ describe('K8sBaseList', () => {
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: new APIError({
|
||||
httpStatusCode: 500,
|
||||
error: {
|
||||
code: '500',
|
||||
message: 'Failed to fetch pods',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
error: 'Failed to fetch pods',
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
@@ -956,413 +941,4 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with object itemKey (selectedItem + cluster + namespace params)', () => {
|
||||
const itemId = 'obj-item';
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<
|
||||
NonNullable<K8sBaseListProps<TestItemWithTitle>['fetchListData']>
|
||||
>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItemWithTitle>['fetchListData']>>
|
||||
>();
|
||||
|
||||
const getLatestParam = (key: string): string | undefined =>
|
||||
onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get(key))
|
||||
.filter(Boolean)
|
||||
.pop() as string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
openInNewTabMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: `PodId:${itemId}`, title: `PodTitle:${itemId}` }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set selectedItem, cluster and namespace params on row click', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): SelectedItemParams => ({
|
||||
selectedItem: row.id,
|
||||
clusterName: 'prod-cluster',
|
||||
namespaceName: 'default-ns',
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRowEl = await screen.findByText(`PodId:${itemId}`);
|
||||
await user.click(firstRowEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestParam('selectedItem')).toBe(`PodId:${itemId}`);
|
||||
expect(getLatestParam('selectedItemClusterName')).toBe('prod-cluster');
|
||||
expect(getLatestParam('selectedItemNamespaceName')).toBe('default-ns');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include cluster and namespace params in new tab URL on ctrl+click', async () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): SelectedItemParams => ({
|
||||
selectedItem: row.id,
|
||||
clusterName: 'prod-cluster',
|
||||
namespaceName: 'default-ns',
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRow = await screen.findByText(`PodId:${itemId}`);
|
||||
fireEvent.click(firstRow, { ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const url = openInNewTabMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain(`selectedItem=PodId%3A${itemId}`);
|
||||
expect(url).toContain('selectedItemClusterName=prod-cluster');
|
||||
expect(url).toContain('selectedItemNamespaceName=default-ns');
|
||||
});
|
||||
|
||||
it('should omit null cluster/namespace params in new tab URL', async () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): SelectedItemParams => ({
|
||||
selectedItem: row.id,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRow = await screen.findByText(`PodId:${itemId}`);
|
||||
fireEvent.click(firstRow, { ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const url = openInNewTabMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain(`selectedItem=PodId%3A${itemId}`);
|
||||
expect(url).not.toContain('selectedItemClusterName');
|
||||
expect(url).not.toContain('selectedItemNamespaceName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instrumentation checks callout', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: 'item-1' }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render callout when ready is true', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: true,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('item-1');
|
||||
|
||||
expect(screen.queryByText('Instrumentation checks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render callout when no entries exist', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: null,
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: null,
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('item-1');
|
||||
|
||||
expect(screen.queryByText('Instrumentation checks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render callout with present entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage', 'k8s.pod.memory.usage'],
|
||||
},
|
||||
],
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: null,
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Default enabled metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.usage, k8s.pod.memory.usage'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('otel-collector'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render callout with missing entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: null,
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.limit'],
|
||||
documentationLink: 'https://example.com/docs',
|
||||
},
|
||||
],
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Missing default metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.limit'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Learn here')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should trigger recheck on button click', async () => {
|
||||
let recheckCallCount = 0;
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v2/infra_monitoring/checks',
|
||||
(_, res, ctx) => {
|
||||
recheckCallCount++;
|
||||
return res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
const initialCallCount = recheckCallCount;
|
||||
|
||||
const recheckBtn = screen.getByTestId('instrumentation-checks-recheck-btn');
|
||||
await user.click(recheckBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recheckCallCount).toBeGreaterThan(initialCallCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render both present and missing entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
attributes: ['k8s.namespace.name'],
|
||||
},
|
||||
],
|
||||
missingDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.memory.limit'],
|
||||
},
|
||||
],
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
// Present entries
|
||||
await expect(
|
||||
screen.findByText('Default enabled metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.usage'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Required attributes'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.namespace.name'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Missing entries
|
||||
await expect(
|
||||
screen.findByText('Missing default metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.memory.limit'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildExpressionFromSelectedItemParams,
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
|
||||
describe('buildExpressionFromSelectedItemParams', () => {
|
||||
it('should build expression from params with all values', () => {
|
||||
const result = buildExpressionFromSelectedItemParams(
|
||||
{
|
||||
selectedItem: 'nginx',
|
||||
clusterName: 'prod',
|
||||
namespaceName: 'default',
|
||||
},
|
||||
'k8s.deployment.name',
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.deployment.name = 'nginx' AND k8s.cluster.name = 'prod' AND k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip null values', () => {
|
||||
const result = buildExpressionFromSelectedItemParams(
|
||||
{
|
||||
selectedItem: 'nginx',
|
||||
clusterName: null,
|
||||
namespaceName: 'default',
|
||||
},
|
||||
'k8s.deployment.name',
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.deployment.name = 'nginx' AND k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle only selectedItem', () => {
|
||||
const result = buildExpressionFromSelectedItemParams(
|
||||
{
|
||||
selectedItem: 'pod-123',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
},
|
||||
'k8s.pod.uid',
|
||||
);
|
||||
|
||||
expect(result).toBe("k8s.pod.uid = 'pod-123'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEventsExpression', () => {
|
||||
it('should build expression with kind, name, cluster and attribute-context namespace', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Deployment',
|
||||
objectName: 'nginx',
|
||||
clusterName: 'prod',
|
||||
namespaceName: 'default',
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.object.kind = 'Deployment' AND k8s.object.name = 'nginx' AND k8s.cluster.name = 'prod' AND attribute.k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should always include kind and name, even when name is empty', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Node',
|
||||
objectName: '',
|
||||
});
|
||||
|
||||
expect(result).toBe("k8s.object.kind = 'Node' AND k8s.object.name = ''");
|
||||
});
|
||||
|
||||
it('should skip cluster and namespace clauses when values are missing', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Pod',
|
||||
objectName: 'pod-1',
|
||||
clusterName: null,
|
||||
namespaceName: undefined,
|
||||
});
|
||||
|
||||
expect(result).toBe("k8s.object.kind = 'Pod' AND k8s.object.name = 'pod-1'");
|
||||
});
|
||||
|
||||
it('should escape values with quotes', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Pod',
|
||||
objectName: "po'd",
|
||||
namespaceName: 'ns-1',
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
`k8s.object.kind = 'Pod' AND k8s.object.name = 'po\\'d' AND attribute.k8s.namespace.name = 'ns-1'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLogsTracesExpression', () => {
|
||||
it('should build expression with main attribute, cluster and namespace', () => {
|
||||
const result = buildLogsTracesExpression({
|
||||
mainAttributeKey: 'k8s.deployment.name',
|
||||
mainAttributeValue: 'nginx',
|
||||
clusterName: 'prod',
|
||||
namespaceName: 'default',
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.deployment.name = 'nginx' AND k8s.cluster.name = 'prod' AND k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip clauses with empty values', () => {
|
||||
const result = buildLogsTracesExpression({
|
||||
mainAttributeKey: 'k8s.node.name',
|
||||
mainAttributeValue: 'node-1',
|
||||
clusterName: '',
|
||||
namespaceName: null,
|
||||
});
|
||||
|
||||
expect(result).toBe("k8s.node.name = 'node-1'");
|
||||
});
|
||||
|
||||
it('should return empty string when all values are missing', () => {
|
||||
const result = buildLogsTracesExpression({
|
||||
mainAttributeKey: 'k8s.pod.name',
|
||||
mainAttributeValue: '',
|
||||
});
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
.countsContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countCard {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.navigateButton {
|
||||
position: absolute;
|
||||
top: var(--spacing-4);
|
||||
right: var(--spacing-4);
|
||||
|
||||
--button-padding: var(--spacing-1);
|
||||
--button-height: 24px;
|
||||
--button-width: 24px;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
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 styles from './EntityCountsSection.module.scss';
|
||||
|
||||
export interface EntityCountConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => number;
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
interface EntityCountsSectionProps<T> {
|
||||
entity: T;
|
||||
countsConfig: EntityCountConfig<T>[];
|
||||
selectedItem: string;
|
||||
filterExpression: string;
|
||||
closeDrawer: () => void;
|
||||
}
|
||||
|
||||
export function EntityCountsSection<T>({
|
||||
entity,
|
||||
countsConfig,
|
||||
selectedItem,
|
||||
filterExpression,
|
||||
closeDrawer,
|
||||
}: EntityCountsSectionProps<T>): JSX.Element {
|
||||
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 },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.countsContainer}>
|
||||
{countsConfig.map((config) => (
|
||||
<div
|
||||
key={config.label}
|
||||
className={styles.countCard}
|
||||
data-testid={`count-card-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.countValue} size="xl" weight="semibold">
|
||||
{config.getValue(entity) || '-'}
|
||||
</Typography.Text>
|
||||
<Link
|
||||
to={buildNavigationUrl(config.targetCategory)}
|
||||
onClick={closeDrawer}
|
||||
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Tooltip
|
||||
title={`View ${config.label.toLowerCase()} of '${selectedItem}'`}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.navigateButton}
|
||||
prefix={<Compass size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.checkContainer {
|
||||
padding: 0px var(--spacing-4) var(--spacing-4) var(--spacing-4);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entriesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { MouseEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { RefreshCw } from '@signozhq/icons';
|
||||
import setLocalStorage from 'api/browser/localstorage/set';
|
||||
import {
|
||||
invalidateGetChecks,
|
||||
useGetChecks,
|
||||
} from 'api/generated/services/inframonitoring';
|
||||
import { InframonitoringtypesCheckTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
|
||||
import styles from './K8sInstrumentationChecksCallout.module.scss';
|
||||
import { MissingEntryRow } from './MissingEntryRow';
|
||||
import { PresentEntryRow } from './PresentEntryRow';
|
||||
import {
|
||||
ENTITY_TO_CHECK_TYPE,
|
||||
getStorageKey,
|
||||
hasAnyEntries,
|
||||
hasMissingEntries,
|
||||
readExpandedState,
|
||||
} from './utils';
|
||||
|
||||
export interface InstrumentationChecksCalloutProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
export function K8sInstrumentationChecksCallout({
|
||||
entity,
|
||||
}: InstrumentationChecksCalloutProps): JSX.Element | null {
|
||||
const checkType = ENTITY_TO_CHECK_TYPE[entity];
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(() => readExpandedState(entity));
|
||||
|
||||
const { data, isLoading, isFetching } = useGetChecks(
|
||||
{ type: checkType as InframonitoringtypesCheckTypeDTO },
|
||||
{ query: { enabled: !!checkType } },
|
||||
);
|
||||
|
||||
const handleRecheck = useCallback(
|
||||
(e: MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (checkType) {
|
||||
void invalidateGetChecks(queryClient, { type: checkType });
|
||||
}
|
||||
},
|
||||
[queryClient, checkType],
|
||||
);
|
||||
|
||||
const handleExpandToggle = useCallback((): void => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
setLocalStorage(getStorageKey(entity), String(next));
|
||||
return next;
|
||||
});
|
||||
}, [entity]);
|
||||
|
||||
const checksData = data?.data;
|
||||
|
||||
const hasMissingItems = useMemo(
|
||||
() => (checksData ? hasMissingEntries(checksData) : false),
|
||||
[checksData],
|
||||
);
|
||||
|
||||
if (!checkType || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!checksData || checksData.ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasAnyEntries(checksData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.checkContainer}>
|
||||
<Callout
|
||||
type={hasMissingItems ? 'warning' : 'info'}
|
||||
showIcon
|
||||
size="medium"
|
||||
title={
|
||||
<div className={styles.header}>
|
||||
Instrumentation checks
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
size="sm"
|
||||
onClick={handleRecheck}
|
||||
loading={isFetching}
|
||||
prefix={<RefreshCw size={12} />}
|
||||
data-testid="instrumentation-checks-recheck-btn"
|
||||
>
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
action="expandable"
|
||||
defaultExpanded={isExpanded}
|
||||
onClick={handleExpandToggle}
|
||||
>
|
||||
<div className={styles.container} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.entriesList}>
|
||||
{checksData.presentDefaultEnabledMetrics?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-default-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Default enabled metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.presentOptionalMetrics?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-optional-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Optional metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.presentRequiredAttributes?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-attrs-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Required attributes"
|
||||
itemType="attributes"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingDefaultEnabledMetrics?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-default-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing default metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingOptionalMetrics?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-optional-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing optional metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingRequiredAttributes?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-attrs-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing required attributes"
|
||||
itemType="attributes"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
.entryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.missingIcon {
|
||||
color: var(--warning-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.learnMoreLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--callout-warning-border);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import styles from './MissingEntryRow.module.scss';
|
||||
import { ExternalLink, TriangleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import type {
|
||||
InframonitoringtypesMissingAttributesComponentEntryDTO,
|
||||
InframonitoringtypesMissingMetricsComponentEntryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
type MissingEntryRowProps =
|
||||
| {
|
||||
entry: InframonitoringtypesMissingMetricsComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'metrics';
|
||||
}
|
||||
| {
|
||||
entry: InframonitoringtypesMissingAttributesComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'attributes';
|
||||
};
|
||||
|
||||
export function MissingEntryRow({
|
||||
entry,
|
||||
typeLabel,
|
||||
itemType,
|
||||
}: MissingEntryRowProps): JSX.Element {
|
||||
const items = itemType === 'metrics' ? entry.metrics : entry.attributes;
|
||||
|
||||
return (
|
||||
<div className={styles.entryRow}>
|
||||
<TriangleAlert size={14} className={styles.missingIcon} />
|
||||
<Typography.Text size="base" weight="medium">
|
||||
{typeLabel}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.entryRowMetric}
|
||||
>
|
||||
{items?.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" italic color="muted">
|
||||
{entry.associatedComponent.name}
|
||||
</Typography.Text>
|
||||
{entry.documentationLink && (
|
||||
<Typography.Link
|
||||
href={entry.documentationLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="base"
|
||||
className={styles.learnMoreLink}
|
||||
>
|
||||
Learn here
|
||||
<ExternalLink size={12} />
|
||||
</Typography.Link>
|
||||
)}
|
||||
<Divider className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.entryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.presentIcon {
|
||||
color: var(--success-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--callout-warning-border);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import styles from './PresentEntryRow.module.scss';
|
||||
import { CircleCheck } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import type {
|
||||
InframonitoringtypesAttributesComponentEntryDTO,
|
||||
InframonitoringtypesMetricsComponentEntryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
type PresentEntryRowProps =
|
||||
| {
|
||||
entry: InframonitoringtypesMetricsComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'metrics';
|
||||
}
|
||||
| {
|
||||
entry: InframonitoringtypesAttributesComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'attributes';
|
||||
};
|
||||
|
||||
export function PresentEntryRow({
|
||||
entry,
|
||||
typeLabel,
|
||||
itemType,
|
||||
}: PresentEntryRowProps): JSX.Element {
|
||||
const items = itemType === 'metrics' ? entry.metrics : entry.attributes;
|
||||
|
||||
return (
|
||||
<div className={styles.entryRow}>
|
||||
<CircleCheck size={14} className={styles.presentIcon} />
|
||||
<Typography.Text size="base" weight="medium">
|
||||
{typeLabel}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.entryRowMetric}
|
||||
>
|
||||
{items?.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" italic color="muted">
|
||||
{entry.associatedComponent.name}
|
||||
</Typography.Text>
|
||||
<Divider className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import getLocalStorage from 'api/browser/localstorage/get';
|
||||
import {
|
||||
InframonitoringtypesChecksDTO,
|
||||
InframonitoringtypesCheckTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
|
||||
export const ENTITY_TO_CHECK_TYPE: Record<
|
||||
InfraMonitoringEntity,
|
||||
InframonitoringtypesCheckTypeDTO
|
||||
> = {
|
||||
[InfraMonitoringEntity.HOSTS]: InframonitoringtypesCheckTypeDTO.hosts,
|
||||
[InfraMonitoringEntity.PODS]: InframonitoringtypesCheckTypeDTO.pods,
|
||||
[InfraMonitoringEntity.NODES]: InframonitoringtypesCheckTypeDTO.nodes,
|
||||
[InfraMonitoringEntity.NAMESPACES]:
|
||||
InframonitoringtypesCheckTypeDTO.namespaces,
|
||||
[InfraMonitoringEntity.CLUSTERS]: InframonitoringtypesCheckTypeDTO.clusters,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]:
|
||||
InframonitoringtypesCheckTypeDTO.deployments,
|
||||
[InfraMonitoringEntity.STATEFULSETS]:
|
||||
InframonitoringtypesCheckTypeDTO.statefulsets,
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
InframonitoringtypesCheckTypeDTO.daemonsets,
|
||||
[InfraMonitoringEntity.JOBS]: InframonitoringtypesCheckTypeDTO.jobs,
|
||||
[InfraMonitoringEntity.VOLUMES]: InframonitoringtypesCheckTypeDTO.volumes,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
InframonitoringtypesCheckTypeDTO.kube_containers,
|
||||
};
|
||||
|
||||
export function hasMissingEntries(
|
||||
data: InframonitoringtypesChecksDTO,
|
||||
): boolean {
|
||||
return (
|
||||
(data.missingDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingRequiredAttributes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnyEntries(data: InframonitoringtypesChecksDTO): boolean {
|
||||
return (
|
||||
(data.presentDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.presentOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.presentRequiredAttributes?.length ?? 0) > 0 ||
|
||||
(data.missingDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingRequiredAttributes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = '@signozhq/k8s-instrumentation-checks-expanded';
|
||||
|
||||
export function getStorageKey(entity: InfraMonitoringEntity): string {
|
||||
return `${STORAGE_PREFIX}-${entity}`;
|
||||
}
|
||||
|
||||
export function readExpandedState(entity: InfraMonitoringEntity): boolean {
|
||||
const stored = getLocalStorage(getStorageKey(entity));
|
||||
return stored === null ? true : stored === 'true';
|
||||
}
|
||||
@@ -2,12 +2,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import styles from './utils.module.scss';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
convertFiltersToExpression,
|
||||
formatValueForExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
const dotToUnder: Record<string, string> = {
|
||||
'os.type': 'os_type',
|
||||
@@ -94,91 +89,3 @@ export function buildExpressionFromGroupMeta(
|
||||
}
|
||||
return parent || metaExpression;
|
||||
}
|
||||
|
||||
export interface EventsExpressionParams {
|
||||
objectKind: string;
|
||||
objectName: string;
|
||||
clusterName?: string | null;
|
||||
namespaceName?: string | null;
|
||||
}
|
||||
|
||||
export function buildEventsExpression(params: EventsExpressionParams): string {
|
||||
const clauses: string[] = [
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = ${formatValueForExpression(params.objectKind)}`,
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${formatValueForExpression(params.objectName)}`,
|
||||
];
|
||||
|
||||
if (params.clusterName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(params.clusterName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// the other attributes are resource., and fallbacks correctly without prefix
|
||||
// this one needs attribute. prefix otherwise it fails the query
|
||||
if (params.namespaceName) {
|
||||
clauses.push(
|
||||
`attribute.${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(params.namespaceName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return clauses.join(' AND ');
|
||||
}
|
||||
|
||||
export interface LogsTracesExpressionParams {
|
||||
mainAttributeKey: string;
|
||||
mainAttributeValue?: string | null;
|
||||
clusterName?: string | null;
|
||||
namespaceName?: string | null;
|
||||
}
|
||||
|
||||
export function buildLogsTracesExpression(
|
||||
params: LogsTracesExpressionParams,
|
||||
): string {
|
||||
const clauses: string[] = [];
|
||||
|
||||
if (params.mainAttributeValue) {
|
||||
clauses.push(
|
||||
`${params.mainAttributeKey} = ${formatValueForExpression(params.mainAttributeValue)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (params.clusterName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(params.clusterName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (params.namespaceName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(params.namespaceName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return clauses.join(' AND ');
|
||||
}
|
||||
|
||||
export function buildExpressionFromSelectedItemParams(
|
||||
params: SelectedItemParams,
|
||||
mainAttributeKey: string,
|
||||
): string {
|
||||
const clauses: string[] = [];
|
||||
|
||||
if (params.selectedItem) {
|
||||
clauses.push(
|
||||
`${mainAttributeKey} = ${formatValueForExpression(params.selectedItem)}`,
|
||||
);
|
||||
}
|
||||
if (params.clusterName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(params.clusterName)}`,
|
||||
);
|
||||
}
|
||||
if (params.namespaceName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(params.namespaceName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return clauses.join(' AND ');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listClusters } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesClusterRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -18,9 +14,7 @@ import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
clusterWidgetInfo,
|
||||
getClusterMetricsQueryPayload,
|
||||
k8sClusterDetailsCountsConfig,
|
||||
k8sClusterDetailsMetadataConfig,
|
||||
k8sClusterGetCountsFilterExpression,
|
||||
k8sClusterGetEntityName,
|
||||
k8sClusterGetSelectedItemExpression,
|
||||
k8sClusterInitialEventsExpression,
|
||||
@@ -73,12 +67,13 @@ function K8sClustersList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch clusters';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesClusterRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -91,7 +86,7 @@ function K8sClustersList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesClusterRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listClusters(
|
||||
@@ -109,10 +104,11 @@ function K8sClustersList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch cluster';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -140,8 +136,6 @@ function K8sClustersList({
|
||||
getInitialLogTracesExpression={k8sClusterInitialLogTracesExpression}
|
||||
getInitialEventsExpression={k8sClusterInitialEventsExpression}
|
||||
metadataConfig={k8sClusterDetailsMetadataConfig}
|
||||
countsConfig={k8sClusterDetailsCountsConfig}
|
||||
getCountsFilterExpression={k8sClusterGetCountsFilterExpression}
|
||||
entityWidgetInfo={clusterWidgetInfo}
|
||||
getEntityQueryPayload={getClusterMetricsQueryPayload}
|
||||
queryKeyPrefix="cluster"
|
||||
|
||||
@@ -6,131 +6,67 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
|
||||
export const k8sClusterGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
): string =>
|
||||
`k8s.cluster.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
|
||||
selectedItemId: string,
|
||||
): string => `k8s.cluster.name = ${formatValueForExpression(selectedItemId)}`;
|
||||
|
||||
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesClusterRecordDTO>[] =
|
||||
[{ label: 'Cluster Name', getValue: (p): string => p.clusterName || '' }];
|
||||
|
||||
export const k8sClusterDetailsCountsConfig: K8sDetailsCountConfig<InframonitoringtypesClusterRecordDTO>[] =
|
||||
[
|
||||
{
|
||||
label: 'Namespaces',
|
||||
getValue: (p): number => p.counts?.namespaces ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.NAMESPACES,
|
||||
},
|
||||
{
|
||||
label: 'Nodes',
|
||||
getValue: (p): number => p.counts?.nodes ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.NODES,
|
||||
},
|
||||
{
|
||||
label: 'Deployments',
|
||||
getValue: (p): number => p.counts?.deployments ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
},
|
||||
{
|
||||
label: 'StatefulSets',
|
||||
getValue: (p): number => p.counts?.statefulSets ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.STATEFULSETS,
|
||||
},
|
||||
{
|
||||
label: 'DaemonSets',
|
||||
getValue: (p): number => p.counts?.daemonSets ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.DAEMONSETS,
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
getValue: (p): number => p.counts?.jobs ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.JOBS,
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sClusterInitialEventsExpression = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string =>
|
||||
buildEventsExpression({
|
||||
objectKind: 'Cluster',
|
||||
objectName: item.clusterName || '',
|
||||
});
|
||||
): string => {
|
||||
const objectName = formatValueForExpression(item.clusterName || '');
|
||||
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Cluster' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
|
||||
};
|
||||
|
||||
export const k8sClusterInitialLogTracesExpression = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string =>
|
||||
buildLogsTracesExpression({
|
||||
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
mainAttributeValue: item.clusterName,
|
||||
});
|
||||
): string => {
|
||||
const clusterName = formatValueForExpression(item.clusterName || '');
|
||||
return `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${clusterName}`;
|
||||
};
|
||||
|
||||
export const k8sClusterGetEntityName = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string => item.clusterName || '';
|
||||
|
||||
export const k8sClusterGetCountsFilterExpression = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string =>
|
||||
`k8s.cluster.name = ${formatValueForExpression(item.clusterName ?? '')}`;
|
||||
|
||||
export const clusterWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage, allocatable',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#cpu-usage-allocatable',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage, allocatable',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#memory-usage-allocatable',
|
||||
},
|
||||
{
|
||||
title: 'Ready Nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#ready-nodes',
|
||||
},
|
||||
{
|
||||
title: 'NotReady Nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#notready-nodes',
|
||||
},
|
||||
{
|
||||
title: 'Deployments available and desired',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#deployments-available-and-desired',
|
||||
},
|
||||
{
|
||||
title: 'Statefulset pods',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#statefulset-pods',
|
||||
},
|
||||
{
|
||||
title: 'Daemonset nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#daemonset-nodes',
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#jobs',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
|
||||
import {
|
||||
CellValueTooltip,
|
||||
GroupedStatusCounts,
|
||||
@@ -77,7 +77,11 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const clusterName = value as string;
|
||||
return <CellValueTooltip value={clusterName} />;
|
||||
return (
|
||||
<CellValueTooltip value={clusterName}>
|
||||
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
|
||||
</CellValueTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,25 +121,23 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'podCountsByStatus',
|
||||
id: 'podCountsByPhase',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#pod-counts-by-status">
|
||||
Pod Status
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#pod-counts-by-phase">
|
||||
Pod Phases
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesClusterRecordDTO['podCountsByStatus'] =>
|
||||
row.podCountsByStatus,
|
||||
accessorFn: (row): InframonitoringtypesClusterRecordDTO['podCountsByPhase'] =>
|
||||
row.podCountsByPhase,
|
||||
width: { min: 250 },
|
||||
enableSort: false,
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
const podCountsByStatus = row.podCountsByStatus;
|
||||
if (!podCountsByStatus) {
|
||||
const podCountsByPhase = row.podCountsByPhase;
|
||||
if (!podCountsByPhase) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
|
||||
<GroupedStatusCounts items={getPodPhaseStatusItems(row.podCountsByPhase)} />
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useCallback } from 'react';
|
||||
import { listDaemonSets } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesDaemonSetRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
daemonSetWidgetInfo,
|
||||
getDaemonSetMetricsQueryPayload,
|
||||
getDaemonSetPodMetricsQueryPayload,
|
||||
k8sDaemonSetDetailsMetadataConfig,
|
||||
k8sDaemonSetGetEntityName,
|
||||
k8sDaemonSetGetSelectedItemExpression,
|
||||
@@ -31,8 +24,6 @@ import {
|
||||
getK8sDaemonSetRowKey,
|
||||
k8sDaemonSetsColumnsConfig,
|
||||
} from './table.config';
|
||||
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
|
||||
|
||||
function K8sDaemonSetsList({
|
||||
controlListPrefix,
|
||||
}: {
|
||||
@@ -73,12 +64,13 @@ function K8sDaemonSetsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch daemonsets';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesDaemonSetRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -90,7 +82,7 @@ function K8sDaemonSetsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesDaemonSetRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listDaemonSets(
|
||||
@@ -107,29 +99,19 @@ function K8sDaemonSetsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch daemonset';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const customTabs = useMemo(
|
||||
() => [
|
||||
createPodMetricsTab<InframonitoringtypesDaemonSetRecordDTO>({
|
||||
getQueryPayload: getDaemonSetPodMetricsQueryPayload,
|
||||
category: InfraMonitoringEntity.DAEMONSETS,
|
||||
queryKey: 'daemonSetPodMetrics',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO, SelectedItemParams>
|
||||
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO>
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
tableColumns={k8sDaemonSetsColumnsConfig}
|
||||
@@ -150,7 +132,6 @@ function K8sDaemonSetsList({
|
||||
entityWidgetInfo={daemonSetWidgetInfo}
|
||||
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
|
||||
queryKeyPrefix="daemonset"
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,24 +7,13 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
} from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildExpressionFromSelectedItemParams,
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
|
||||
export const k8sDaemonSetGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
selectedItemId: string,
|
||||
): string =>
|
||||
buildExpressionFromSelectedItemParams(
|
||||
params,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
);
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME} = ${formatValueForExpression(selectedItemId)}`;
|
||||
|
||||
export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesDaemonSetRecordDTO>[] =
|
||||
[
|
||||
@@ -48,23 +37,12 @@ export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframo
|
||||
export const k8sDaemonSetInitialEventsExpression = (
|
||||
item: InframonitoringtypesDaemonSetRecordDTO,
|
||||
): string =>
|
||||
buildEventsExpression({
|
||||
objectKind: 'DaemonSet',
|
||||
objectName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '',
|
||||
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'DaemonSet' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '')}`;
|
||||
|
||||
export const k8sDaemonSetInitialLogTracesExpression = (
|
||||
item: InframonitoringtypesDaemonSetRecordDTO,
|
||||
): string =>
|
||||
buildLogsTracesExpression({
|
||||
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
mainAttributeValue:
|
||||
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME],
|
||||
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '')} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '')}`;
|
||||
|
||||
export const k8sDaemonSetGetEntityName = (
|
||||
item: InframonitoringtypesDaemonSetRecordDTO,
|
||||
@@ -74,25 +52,18 @@ export const daemonSetWidgetInfo = [
|
||||
{
|
||||
title: 'CPU usage, request, limits',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#cpu-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#memory-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/daemonsets/#network-io',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#network-errors-count',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -144,40 +115,6 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
? 'k8s.namespace.name'
|
||||
: 'k8s_namespace_name';
|
||||
|
||||
const k8sClusterNameKey = dotMetricsEnabled
|
||||
? 'k8s.cluster.name'
|
||||
: 'k8s_cluster_name';
|
||||
|
||||
const clusterName =
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
|
||||
const namespaceName =
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
|
||||
|
||||
const filters = [
|
||||
{
|
||||
id: 'f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_cluster_name--string--tag--false',
|
||||
key: k8sClusterNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: clusterName,
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: namespaceName,
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -211,7 +148,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -253,7 +202,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -295,7 +256,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -371,7 +344,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -413,7 +398,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -455,7 +452,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -531,7 +540,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -620,7 +641,19 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
{
|
||||
id: '47b3adae',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
|
||||
'',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -679,29 +712,3 @@ export const getDaemonSetMetricsQueryPayload = (
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getDaemonSetPodMetricsQueryPayload = (
|
||||
daemonSet: InframonitoringtypesDaemonSetRecordDTO,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
): GetQueryResultsProps[] => {
|
||||
const k8sDaemonSetNameKey = dotMetricsEnabled
|
||||
? 'k8s.daemonset.name'
|
||||
: 'k8s_daemonset_name';
|
||||
|
||||
return getPodUtilizationByPodQueryPayloads(
|
||||
{
|
||||
workloadNameKey: k8sDaemonSetNameKey,
|
||||
workloadNameValue:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '',
|
||||
clusterName:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
|
||||
namespaceName:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
|
||||
},
|
||||
start,
|
||||
end,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
|
||||
import {
|
||||
CellValueTooltip,
|
||||
EntityProgressBar,
|
||||
@@ -32,15 +31,8 @@ export function getK8sDaemonSetRowKey(
|
||||
|
||||
export function getK8sDaemonSetItemKey(
|
||||
daemonSet: InframonitoringtypesDaemonSetRecordDTO,
|
||||
): SelectedItemParams {
|
||||
return {
|
||||
selectedItem:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? null,
|
||||
clusterName:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
|
||||
namespaceName:
|
||||
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
|
||||
};
|
||||
): string {
|
||||
return daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '';
|
||||
}
|
||||
|
||||
export type DaemonSetTableColumnConfig =
|
||||
@@ -87,7 +79,11 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const daemonsetName = value as string;
|
||||
return <CellValueTooltip value={daemonsetName} />;
|
||||
return (
|
||||
<CellValueTooltip value={daemonsetName}>
|
||||
<TanStackTable.Text>{daemonsetName}</TanStackTable.Text>
|
||||
</CellValueTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -104,64 +100,60 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
enableResize: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const namespaceName = value as string;
|
||||
return <CellValueTooltip value={namespaceName} />;
|
||||
return (
|
||||
<CellValueTooltip value={namespaceName}>
|
||||
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
|
||||
</CellValueTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pod_counts_by_status',
|
||||
id: 'pod_counts_by_phase',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#pod-counts-by-status">
|
||||
Pod Status
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#pod-counts-by-phase">
|
||||
Pod Phases
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesDaemonSetRecordDTO['podCountsByStatus'] =>
|
||||
row.podCountsByStatus,
|
||||
): InframonitoringtypesDaemonSetRecordDTO['podCountsByPhase'] =>
|
||||
row.podCountsByPhase,
|
||||
width: { min: 250 },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
const podCountsByStatus = row.podCountsByStatus;
|
||||
if (!podCountsByStatus) {
|
||||
const podCountsByPhase = row.podCountsByPhase;
|
||||
if (!podCountsByPhase) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
|
||||
return (
|
||||
<GroupedStatusCounts items={getPodPhaseStatusItems(podCountsByPhase)} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'scheduled_nodes',
|
||||
id: 'node_status',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#scheduled-nodes">
|
||||
Scheduled Nodes
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#node-status">
|
||||
Node Status
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.currentNodes,
|
||||
width: { min: 210 },
|
||||
width: { min: 180 },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
cell: ({ row }): React.ReactNode => (
|
||||
<GroupedStatusCounts
|
||||
items={[
|
||||
{
|
||||
value: row.readyNodes,
|
||||
label: 'Ready',
|
||||
color: Color.BG_FOREST_500,
|
||||
},
|
||||
{
|
||||
value: row.currentNodes,
|
||||
label: 'Current',
|
||||
color: Color.BG_ROBIN_500,
|
||||
color: Color.BG_FOREST_500,
|
||||
},
|
||||
{
|
||||
value: row.desiredNodes,
|
||||
label: 'Desired',
|
||||
color: Color.BG_SAKURA_400,
|
||||
},
|
||||
{
|
||||
value: row.misscheduledNodes,
|
||||
label: 'Misscheduled',
|
||||
color: Color.BG_AMBER_500,
|
||||
color: Color.BG_ROBIN_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -320,30 +312,6 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ready_nodes',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
|
||||
Ready Nodes
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.readyNodes,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const readyNodes = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={readyNodes}
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="ready node"
|
||||
>
|
||||
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'current_nodes',
|
||||
header: (): React.ReactNode => (
|
||||
@@ -392,28 +360,4 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'misscheduled_nodes',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
|
||||
Misscheduled Nodes
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.misscheduledNodes,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const misscheduledNodes = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={misscheduledNodes}
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="misscheduled node"
|
||||
>
|
||||
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useCallback } from 'react';
|
||||
import { listDeployments } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesDeploymentRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
deploymentWidgetInfo,
|
||||
getDeploymentMetricsQueryPayload,
|
||||
getDeploymentPodMetricsQueryPayload,
|
||||
k8sDeploymentDetailsMetadataConfig,
|
||||
k8sDeploymentGetEntityName,
|
||||
k8sDeploymentGetSelectedItemExpression,
|
||||
@@ -31,7 +25,6 @@ import {
|
||||
getK8sDeploymentRowKey,
|
||||
k8sDeploymentsColumnsConfig,
|
||||
} from './table.config';
|
||||
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
|
||||
|
||||
function K8sDeploymentsList({
|
||||
controlListPrefix,
|
||||
@@ -74,12 +67,13 @@ function K8sDeploymentsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch deployments';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesDeploymentRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -92,7 +86,7 @@ function K8sDeploymentsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesDeploymentRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listDeployments(
|
||||
@@ -110,30 +104,20 @@ function K8sDeploymentsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch deployment';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const customTabs = useMemo(
|
||||
() => [
|
||||
createPodMetricsTab<InframonitoringtypesDeploymentRecordDTO>({
|
||||
getQueryPayload: getDeploymentPodMetricsQueryPayload,
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
queryKey: 'deploymentPodMetrics',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO, SelectedItemParams>
|
||||
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO>
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
tableColumns={k8sDeploymentsColumnsConfig}
|
||||
@@ -155,7 +139,6 @@ function K8sDeploymentsList({
|
||||
entityWidgetInfo={deploymentWidgetInfo}
|
||||
getEntityQueryPayload={getDeploymentMetricsQueryPayload}
|
||||
queryKeyPrefix="deployment"
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,24 +7,13 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
} from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildExpressionFromSelectedItemParams,
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
|
||||
export const k8sDeploymentGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
selectedItemId: string,
|
||||
): string =>
|
||||
buildExpressionFromSelectedItemParams(
|
||||
params,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
);
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME} = ${formatValueForExpression(selectedItemId)}`;
|
||||
|
||||
export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesDeploymentRecordDTO>[] =
|
||||
[
|
||||
@@ -47,24 +36,24 @@ export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<Infram
|
||||
|
||||
export const k8sDeploymentInitialEventsExpression = (
|
||||
item: InframonitoringtypesDeploymentRecordDTO,
|
||||
): string =>
|
||||
buildEventsExpression({
|
||||
objectKind: 'Deployment',
|
||||
objectName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
|
||||
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
): string => {
|
||||
const objectName = formatValueForExpression(
|
||||
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
|
||||
);
|
||||
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Deployment' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
|
||||
};
|
||||
|
||||
export const k8sDeploymentInitialLogTracesExpression = (
|
||||
item: InframonitoringtypesDeploymentRecordDTO,
|
||||
): string =>
|
||||
buildLogsTracesExpression({
|
||||
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
mainAttributeValue:
|
||||
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME],
|
||||
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
): string => {
|
||||
const deploymentName = formatValueForExpression(
|
||||
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
|
||||
);
|
||||
const namespaceName = formatValueForExpression(
|
||||
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
|
||||
);
|
||||
return `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME} = ${deploymentName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
|
||||
};
|
||||
|
||||
export const k8sDeploymentGetEntityName = (
|
||||
item: InframonitoringtypesDeploymentRecordDTO,
|
||||
@@ -74,25 +63,18 @@ export const deploymentWidgetInfo = [
|
||||
{
|
||||
title: 'CPU usage, request, limits',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#cpu-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#memory-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/deployments/#network-io',
|
||||
},
|
||||
{
|
||||
title: 'Network error count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#network-error-count',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -139,43 +121,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
: 'k8s_deployment_name';
|
||||
|
||||
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
|
||||
const k8sClusterNameKey = dotMetricsEnabled
|
||||
? 'k8s.cluster.name'
|
||||
: 'k8s_cluster_name';
|
||||
const k8sNamespaceNameKey = dotMetricsEnabled
|
||||
? 'k8s.namespace.name'
|
||||
: 'k8s_namespace_name';
|
||||
|
||||
const clusterName =
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
|
||||
|
||||
const namespaceName =
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
|
||||
|
||||
const filters = [
|
||||
{
|
||||
id: 'f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_cluster_name--string--tag--false',
|
||||
key: k8sClusterNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: clusterName,
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'k8s_namespace_name--string--tag--false',
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: namespaceName,
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -210,7 +155,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -252,7 +196,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -294,7 +237,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -370,7 +312,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -412,7 +353,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -454,7 +394,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -530,7 +469,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -619,7 +557,6 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
|
||||
'',
|
||||
},
|
||||
...filters,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
@@ -678,29 +615,3 @@ export const getDeploymentMetricsQueryPayload = (
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getDeploymentPodMetricsQueryPayload = (
|
||||
deployment: InframonitoringtypesDeploymentRecordDTO,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
): GetQueryResultsProps[] => {
|
||||
const k8sDeploymentNameKey = dotMetricsEnabled
|
||||
? 'k8s.deployment.name'
|
||||
: 'k8s_deployment_name';
|
||||
|
||||
return getPodUtilizationByPodQueryPayloads(
|
||||
{
|
||||
workloadNameKey: k8sDeploymentNameKey,
|
||||
workloadNameValue:
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
|
||||
clusterName:
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
|
||||
namespaceName:
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
|
||||
},
|
||||
start,
|
||||
end,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
|
||||
import {
|
||||
CellValueTooltip,
|
||||
EntityProgressBar,
|
||||
@@ -32,15 +31,8 @@ export function getK8sDeploymentRowKey(
|
||||
|
||||
export function getK8sDeploymentItemKey(
|
||||
deployment: InframonitoringtypesDeploymentRecordDTO,
|
||||
): SelectedItemParams {
|
||||
return {
|
||||
selectedItem:
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? null,
|
||||
clusterName:
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
|
||||
namespaceName:
|
||||
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
|
||||
};
|
||||
): string {
|
||||
return deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '';
|
||||
}
|
||||
|
||||
export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDeploymentRecordDTO>[] =
|
||||
@@ -88,7 +80,11 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const deploymentName = value as string;
|
||||
return <CellValueTooltip value={deploymentName} />;
|
||||
return (
|
||||
<CellValueTooltip value={deploymentName}>
|
||||
<TanStackTable.Text>{deploymentName}</TanStackTable.Text>
|
||||
</CellValueTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -108,29 +104,31 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'podCountsByStatus',
|
||||
id: 'podCountsByPhase',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#pod-counts-by-status">
|
||||
Pod Status
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#pod-counts-by-phase">
|
||||
Pod Phases
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): object | undefined => row.podCountsByStatus,
|
||||
accessorFn: (row): object | undefined => row.podCountsByPhase,
|
||||
width: { min: 250 },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
const podCountsByStatus = row.podCountsByStatus;
|
||||
if (!podCountsByStatus) {
|
||||
const podCountsByPhase = row.podCountsByPhase;
|
||||
if (!podCountsByPhase) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
|
||||
return (
|
||||
<GroupedStatusCounts items={getPodPhaseStatusItems(podCountsByPhase)} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pod_replicas',
|
||||
id: 'replica_status',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#pod-replicas">
|
||||
Pod Replicas
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#replica-status">
|
||||
Replica Status
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.availablePods,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
.chartHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-slate-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.metricsExplorerLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.chartHeaderLabel {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Compass, Info } from '@signozhq/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import styles from './ChartHeader.module.scss';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ChartHeaderProps {
|
||||
title: string;
|
||||
docPath?: string;
|
||||
tooltip?: string;
|
||||
metricsExplorerUrl?: string;
|
||||
metricsExplorerTestId?: string;
|
||||
}
|
||||
|
||||
function ChartHeader({
|
||||
title,
|
||||
docPath,
|
||||
tooltip,
|
||||
metricsExplorerUrl,
|
||||
metricsExplorerTestId = 'open-metrics-explorer',
|
||||
}: ChartHeaderProps): JSX.Element {
|
||||
const renderInfoIcon = (): React.ReactNode => {
|
||||
if (docPath) {
|
||||
const tooltipTitle = tooltip || 'Not sure what this represents?';
|
||||
return (
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
|
||||
<Info size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
|
||||
<Info size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartHeader} data-testid="chart-header">
|
||||
<span className={styles.chartHeaderLabel}>{title}</span>
|
||||
{renderInfoIcon()}
|
||||
{metricsExplorerUrl && (
|
||||
<Tooltip title="Open in Metrics Explorer">
|
||||
<Link
|
||||
to={metricsExplorerUrl}
|
||||
className={styles.metricsExplorerLink}
|
||||
data-testid={metricsExplorerTestId}
|
||||
>
|
||||
<Compass size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChartHeader;
|
||||
@@ -14,6 +14,28 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.entityMetricsTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entityMetricsTitle {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.metricsExplorerLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.metricsHeader {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
@@ -22,7 +24,6 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
|
||||
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
import ChartHeader from './ChartHeader';
|
||||
|
||||
import { useEntityMetrics } from './hooks';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
@@ -46,7 +47,6 @@ interface EntityMetricsProps<T> {
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
node: T,
|
||||
@@ -207,24 +207,31 @@ function EntityMetrics<T>({
|
||||
key={entityWidgetInfo[idx].title}
|
||||
className={styles.entityMetricsCol}
|
||||
>
|
||||
<ChartHeader
|
||||
title={entityWidgetInfo[idx].title}
|
||||
docPath={entityWidgetInfo[idx].docPath}
|
||||
metricsExplorerUrl={
|
||||
queryPayloads[idx] && queryPayloads[idx].graphType !== PANEL_TYPES.TABLE
|
||||
? getMetricsExplorerUrl({
|
||||
query: queryPayloads[idx].query,
|
||||
...(selectedInterval && selectedInterval !== 'custom'
|
||||
? { relativeTime: selectedInterval }
|
||||
: {
|
||||
startTimeMs: timeRange.startTime * 1000,
|
||||
endTimeMs: timeRange.endTime * 1000,
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
metricsExplorerTestId={`open-metrics-explorer-${idx}`}
|
||||
/>
|
||||
<div className={styles.entityMetricsTitleContainer}>
|
||||
<span className={styles.entityMetricsTitle}>
|
||||
{entityWidgetInfo[idx].title}
|
||||
</span>
|
||||
{queryPayloads[idx] &&
|
||||
queryPayloads[idx].graphType !== PANEL_TYPES.TABLE && (
|
||||
<Tooltip title="Open in Metrics Explorer">
|
||||
<Link
|
||||
to={getMetricsExplorerUrl({
|
||||
query: queryPayloads[idx].query,
|
||||
...(selectedInterval && selectedInterval !== 'custom'
|
||||
? { relativeTime: selectedInterval }
|
||||
: {
|
||||
startTimeMs: timeRange.startTime * 1000,
|
||||
endTimeMs: timeRange.endTime * 1000,
|
||||
}),
|
||||
})}
|
||||
className={styles.metricsExplorerLink}
|
||||
data-testid={`open-metrics-explorer-${idx}`}
|
||||
>
|
||||
<Compass size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.entityMetricsCard} ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('EntityTraces - Table Rendering', () => {
|
||||
expect(badge).toHaveAttribute('data-variant', 'outline');
|
||||
});
|
||||
|
||||
it('should render - when http method is empty', async () => {
|
||||
it('should render N/A when http method is empty', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [{ httpMethod: '', responseStatusCode: '200' }],
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe('EntityTraces - Table Rendering', () => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText('-')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('N/A')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('httpMethod')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,4 @@
|
||||
|
||||
.cellText {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&[data-novalue='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +89,8 @@ export const getTraceListColumns = (
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography
|
||||
data-testid={key}
|
||||
className={styles.cellText}
|
||||
data-novalue="true"
|
||||
>
|
||||
-
|
||||
<Typography data-testid={key} className={styles.cellText}>
|
||||
N/A
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
@@ -106,9 +102,7 @@ export const getTraceListColumns = (
|
||||
if (!httpMethod) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography className={styles.cellText} data-novalue="true">
|
||||
-
|
||||
</Typography>
|
||||
<Typography className={styles.cellText}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
@@ -135,11 +129,8 @@ export const getTraceListColumns = (
|
||||
if (!isValidCode) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography
|
||||
className={styles.cellText}
|
||||
data-novalue={numericCode === 0 || !statusCode}
|
||||
>
|
||||
{numericCode === 0 || !statusCode ? '-' : statusCode}
|
||||
<Typography className={styles.cellText}>
|
||||
{numericCode === 0 || !statusCode ? 'N/A' : statusCode}
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user