Compare commits

..

7 Commits

Author SHA1 Message Date
Tushar Vats
6064e3ec08 fix: added support for serach in logs 2026-07-15 19:27:08 +05:30
Tushar Vats
63cfbe8bfb fix: convert key not found to warnings for traces (#12091) 2026-07-15 12:33:47 +00:00
Aditya Singh
0c28b8b251 feat: scalar/table serializers for client-side export (3) (#12070)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* refactor(data-export): extract the withUnit header helper

Both the timeseries and the upcoming table serializers append units to headers (and skip display-only ids like 'short'/'none') — move the helper to its own module.

* feat(data-export): add generic table-model serializer

exportTableData serializes any prepared antd-style table model ({name, key, isValueColumn} columns + record rows) into a SerializedTable — raw values in display order, units on value columns only, blanks for missing cells. Surface-agnostic: QueryTable-based tables, dashboard tables and plain antd tables all adapt in a line or two.

* feat(data-export): add scalar queryRange serializer

exportScalarData takes the queryRange response object (formatForWeb webTables payload) + the builder query and serializes via createTableColumnsFromQuery — the exact preparer QueryTable renders from — so exports inherit the on-screen merge, naming and column order 1:1.
2026-07-15 05:37:15 +00:00
Aditya Singh
10a0d262d9 feat: add download option to trace view (#12116) 2026-07-15 05:36:59 +00:00
Vinicius Lourenço
334155a226 fix(infra-monitoring-v2): ensure workloads has filters by cluster+namespace (#12076)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(tanstack): add support for itemKey be object

* feat(infra-monitoring): add base structure for multiple selected items

* refactor(infra-monitoring): add cluster for namespaces, cluster/namespace for volumes + update other entities

* refactor(infra-monitoring): add cluster + namespace for statefulsets

* refactor(infra-monitoring): add cluster + namespace for jobs

* refactor(infra-monitoring): add cluster + namespace for deployments

* refactor(infra-monitoring): add cluster + namespace for daemonsets

* test(infra-monitoring): add new tests for changes of k8s base list

* fix(pr): address comments
2026-07-14 18:24:42 +00:00
Pandey
42de840534 fix(rules): scope alert rule store operations by org (#12117)
* fix(rules): scope alert rule store operations by org

The rule store predicates filtered on the rule id only, so on multi-org
deployments (Cloud/EE/multi-org self-hosted, where orgs share the
instance via the noop sharder) an authenticated user could read, edit or
delete another org's alert rules by supplying the target rule UUID.
ViewAccess/EditAccess only check the caller's own-org role, never the
resource's org, so nothing enforced tenant isolation on the rule itself.

Enforce org scoping at the store layer, which is the durable fix:

- GetStoredRule and DeleteRule now take the caller's orgID and add
  `org_id = ?` to their predicates.
- EditRule adds `org_id = ?` (from the model, which is now always
  sourced from an org-scoped read).
- The manager passes claims.OrgID on every by-id path; GetRule now
  derives the org from claims (it previously fetched by id alone).

Cross-org ids now resolve to NotFound instead of leaking or mutating
another org's rule. Single-org OSS instances are unaffected.

CWE-639 (authorization bypass through user-controlled key).

* refactor(rules): derive claims org id with valuer.MustNewUUID

Claims are pre-validated by the auth middleware, so the NewUUID error
branch is dead code. Follow the handler convention (docs/contributing/
go/handler.md) and use the Must constructor on the by-id rule paths.
2026-07-14 18:00:09 +00:00
Nityananda Gohain
891106d1ca fix: set correct openapi response struct for span mapper list (#12094)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: set correct opapi response model for span mapper list

* fix: change group_id to groupId in response

* fix: format properly

* fix: update fixtures
2026-07-14 15:34:52 +00:00
188 changed files with 5113 additions and 3969 deletions

View File

@@ -8048,6 +8048,15 @@ components:
required:
- items
type: object
SpantypesGettableSpanMappers:
properties:
items:
items:
$ref: '#/components/schemas/SpantypesSpanMapper'
type: array
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -8200,7 +8209,7 @@ components:
type: boolean
fieldContext:
$ref: '#/components/schemas/SpantypesFieldContext'
group_id:
groupId:
type: string
id:
type: string
@@ -8213,7 +8222,7 @@ components:
type: string
required:
- id
- group_id
- groupId
- name
- fieldContext
- config
@@ -13792,7 +13801,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
status:
type: string
required:

View File

@@ -9258,6 +9258,76 @@ 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',
@@ -9504,30 +9574,6 @@ 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;
/**
@@ -9576,45 +9622,6 @@ 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;
/**
@@ -10916,7 +10923,7 @@ export type ListSpanMappersPathParameters = {
groupId: string;
};
export type ListSpanMappers200 = {
data: SpantypesGettableSpanMapperGroupsDTO;
data: SpantypesGettableSpanMappersDTO;
/**
* @type string
*/

View File

@@ -3,6 +3,7 @@ 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';
@@ -18,11 +19,13 @@ 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);
@@ -33,6 +36,7 @@ export default function DownloadOptionsMenu({
const { isDownloading, handleExportRawData } = useExportRawData({
dataSource,
panelType,
});
const handleExport = useCallback(async (): Promise<void> => {

View File

@@ -11,17 +11,17 @@ import { FlatItem, TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type VirtuosoTableRowProps<TData> = ComponentProps<
type VirtuosoTableRowProps<TData, TItemKey = string> = ComponentProps<
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['TableRow']
>
>;
function TanStackCustomTableRow<TData>({
function TanStackCustomTableRow<TData, TItemKey = string>({
item,
context,
...props
}: VirtuosoTableRowProps<TData>): JSX.Element {
}: VirtuosoTableRowProps<TData, TItemKey>): JSX.Element {
const rowId = item.row.id;
const rowData = item.row.original;
@@ -84,9 +84,9 @@ function TanStackCustomTableRow<TData>({
// 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>(
prev: Readonly<VirtuosoTableRowProps<TData>>,
next: Readonly<VirtuosoTableRowProps<TData>>,
function areTableRowPropsEqual<TData, TItemKey = string>(
prev: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
next: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
): boolean {
if (prev.item.row.id !== next.item.row.id) {
return false;
@@ -141,7 +141,9 @@ function areTableRowPropsEqual<TData>(
return true;
}
export default memo(
TanStackCustomTableRow,
areTableRowPropsEqual,
) as typeof TanStackCustomTableRow;
export default memo(TanStackCustomTableRow, areTableRowPropsEqual as any) as <
TData,
TItemKey = string,
>(
props: VirtuosoTableRowProps<TData, TItemKey>,
) => JSX.Element;

View File

@@ -8,23 +8,23 @@ import { TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type TanStackRowCellsProps<TData> = {
type TanStackRowCellsProps<TData, TItemKey = string> = {
row: TanStackRowModel<TData>;
context: TableRowContext<TData> | undefined;
context: TableRowContext<TData, TItemKey> | undefined;
itemKind: 'row' | 'expansion';
hasSingleColumn: boolean;
columnOrderKey: string;
columnVisibilityKey: string;
};
function TanStackRowCellsInner<TData>({
function TanStackRowCellsInner<TData, TItemKey = string>({
row,
context,
itemKind,
hasSingleColumn,
columnOrderKey: _columnOrderKey,
columnVisibilityKey: _columnVisibilityKey,
}: TanStackRowCellsProps<TData>): JSX.Element {
}: TanStackRowCellsProps<TData, TItemKey>): JSX.Element {
const hasHovered = useIsRowHovered(row.id);
const rowData = row.original;
const visibleCells = row.getVisibleCells();
@@ -40,8 +40,10 @@ function TanStackRowCellsInner<TData>({
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 ?? '';
const itemKey = keyData?.itemKey ?? ('' as TItemKey);
// Handle ctrl+click or cmd+click (open in new tab)
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
@@ -131,6 +133,8 @@ function areRowCellsPropsEqual<TData>(
const TanStackRowCells = memo(
TanStackRowCellsInner,
areRowCellsPropsEqual as any,
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
) as <T, TItemKey = string>(
props: TanStackRowCellsProps<T, TItemKey>,
) => JSX.Element;
export default TanStackRowCells;

View File

@@ -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>(
function TanStackTableInner<TData, TItemKey = string>(
{
data,
columns,
@@ -107,7 +107,7 @@ function TanStackTableInner<TData>(
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
}: TanStackTableProps<TData>,
}: TanStackTableProps<TData, TItemKey>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
if (disableVirtualScroll && onEndReached) {
@@ -193,7 +193,7 @@ function TanStackTableInner<TData>(
skeletonRowCount,
});
const { rowKeyData, getRowKeyData } = useRowKeyData({
const { rowKeyData, getRowKeyData } = useRowKeyData<TData, TItemKey>({
data: effectiveData,
isLoading,
getRowKey,
@@ -229,7 +229,7 @@ function TanStackTableInner<TData>(
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
),
[effectiveColumns, isRowActive, getRowKeyData],
);
@@ -356,7 +356,7 @@ function TanStackTableInner<TData>(
[effectiveVisibility, columnIds],
);
const virtuosoContext = useMemo<TableRowContext<TData>>(
const virtuosoContext = useMemo<TableRowContext<TData, TItemKey>>(
() => ({
getRowStyle,
getRowClassName,
@@ -520,13 +520,15 @@ function TanStackTableInner<TData>(
);
type VirtuosoTableComponentProps = ComponentProps<
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['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
@@ -582,7 +584,7 @@ function TanStackTableInner<TData>(
</table>
</div>
) : (
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData, TItemKey>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
@@ -660,8 +662,11 @@ function TanStackTableInner<TData>(
);
}
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
props: TanStackTableProps<TData> & {
const TanStackTableForward = forwardRef(TanStackTableInner) as <
TData,
TItemKey = string,
>(
props: TanStackTableProps<TData, TItemKey> & {
ref?: React.Ref<TanStackTableHandle>;
},
) => JSX.Element;

View File

@@ -57,6 +57,39 @@ 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> = {
@@ -84,7 +117,6 @@ 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' }, '');
});
@@ -97,6 +129,7 @@ describe('TanStackRowCells', () => {
onRowClick,
onRowDeactivate,
isRowActive: () => true,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
@@ -194,6 +227,7 @@ describe('TanStackRowCells', () => {
colCount: 1,
onRowClick,
onRowClickNewTab,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
@@ -216,7 +250,7 @@ describe('TanStackRowCells', () => {
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).not.toHaveBeenCalled();
});
@@ -227,6 +261,7 @@ describe('TanStackRowCells', () => {
colCount: 1,
onRowClick,
onRowClickNewTab,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
@@ -249,7 +284,7 @@ describe('TanStackRowCells', () => {
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).not.toHaveBeenCalled();
});
@@ -260,6 +295,7 @@ describe('TanStackRowCells', () => {
colCount: 1,
onRowClick,
onRowClickNewTab,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',

View File

@@ -614,6 +614,34 @@ 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: {

View File

@@ -42,14 +42,14 @@ export const defaultData: TestRow[] = [
{ id: '3', name: 'Item 3', value: 300 },
];
export type RenderTanStackTableOptions<T> = {
props?: Partial<TanStackTableProps<T>>;
export type RenderTanStackTableOptions<T, TItemKey = string> = {
props?: Partial<TanStackTableProps<T, TItemKey>>;
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
};
export function renderTanStackTable<T = TestRow>(
options: RenderTanStackTableOptions<T> = {},
export function renderTanStackTable<T = TestRow, TItemKey = string>(
options: RenderTanStackTableOptions<T, TItemKey> = {},
): RenderResult {
const { props = {}, queryParams, onUrlUpdate } = options;
@@ -57,7 +57,7 @@ export function renderTanStackTable<T = TestRow>(
data: defaultData as unknown as T[],
columns: defaultColumns as unknown as TableColumnDef<T>[],
...props,
} as TanStackTableProps<T>;
} as TanStackTableProps<T, TItemKey>;
return render(
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
@@ -65,7 +65,7 @@ export function renderTanStackTable<T = TestRow>(
value={{ viewportHeight: 500, itemHeight: 50 }}
>
<TooltipProvider>
<TanStackTable<T> {...mergedProps} />
<TanStackTable<T, TItemKey> {...mergedProps} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,

View File

@@ -123,6 +123,22 @@ 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

View File

@@ -24,17 +24,15 @@ 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 = {
export type RowKeyData<TItemKey = string> = {
/** Final unique key (with duplicate suffix if needed) */
finalKey: string;
/** Business/selection key */
itemKey: string;
itemKey: TItemKey;
/** Group metadata */
groupMeta?: Record<string, string>;
};
@@ -82,14 +80,14 @@ export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type TableRowContext<TData> = {
export type TableRowContext<TData, TItemKey = string> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
@@ -97,7 +95,7 @@ export type TableRowContext<TData> = {
groupMeta?: Record<string, string>,
) => ReactNode;
/** Get key data for a row by index */
getRowKeyData?: (index: number) => RowKeyData | undefined;
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined;
colCount: number;
isDarkMode?: boolean;
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
@@ -147,7 +145,7 @@ export type TanstackTableQueryParamsConfig = {
expanded?: string;
};
export type TanStackTableProps<TData> = {
export type TanStackTableProps<TData, TItemKey = string> = {
data: TData[];
columns: TableColumnDef<TData>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
@@ -172,7 +170,7 @@ export type TanStackTableProps<TData> = {
* 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) => string;
getItemKey?: (row: TData) => TItemKey;
/** 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. */
@@ -181,9 +179,9 @@ export type TanStackTableProps<TData> = {
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (

View File

@@ -1,51 +1,51 @@
import { useCallback, useMemo } from 'react';
export interface RowKeyDataItem {
export interface RowKeyDataItem<TItemKey = string> {
/** Final unique key for the row (with dedup suffix if needed) */
finalKey: string;
/** Item key for tracking (may differ from finalKey) */
itemKey: string;
itemKey: TItemKey;
/** Group metadata when grouped */
groupMeta: Record<string, string> | undefined;
}
export interface UseRowKeyDataOptions<TData> {
export interface UseRowKeyDataOptions<TData, TItemKey = string> {
data: TData[];
isLoading: boolean;
getRowKey?: (item: TData) => string;
getItemKey?: (item: TData) => string;
getItemKey?: (item: TData) => TItemKey;
groupBy?: Array<{ key: string }>;
getGroupKey?: (item: TData) => Record<string, string>;
}
export interface UseRowKeyDataResult {
export interface UseRowKeyDataResult<TItemKey = string> {
/** Array of key data for each row, undefined if getRowKey not provided or loading */
rowKeyData: RowKeyDataItem[] | undefined;
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
rowKeyData: RowKeyDataItem<TItemKey>[] | undefined;
getRowKeyData: (index: number) => RowKeyDataItem<TItemKey> | undefined;
}
/**
* Computes unique row keys with duplicate handling and group prefixes.
*/
export function useRowKeyData<TData>({
export function useRowKeyData<TData, TItemKey = string>({
data,
isLoading,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
}: UseRowKeyDataOptions<TData, TItemKey>): UseRowKeyDataResult<TItemKey> {
// eslint-disable-next-line sonarjs/cognitive-complexity
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
const rowKeyData = useMemo((): RowKeyDataItem<TItemKey>[] | undefined => {
if (!getRowKey || isLoading) {
return undefined;
}
const keyCount = new Map<string, number>();
return data.map((item, index): RowKeyDataItem => {
return data.map((item, index): RowKeyDataItem<TItemKey> => {
const itemIdentifier = getRowKey(item);
const itemKey = getItemKey?.(item) ?? itemIdentifier;
const itemKey = getItemKey?.(item) ?? (itemIdentifier as TItemKey);
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
// Build rowKey with group prefix when grouped

View File

@@ -86,10 +86,10 @@ const buildAccessorFn = <TData>(
};
};
export function buildTanstackColumnDef<TData>(
export function buildTanstackColumnDef<TData, TItemKey = string>(
colDef: TableColumnDef<TData>,
isRowActive?: (row: TData) => boolean,
getRowKeyData?: (index: number) => RowKeyData | undefined,
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined,
): ColumnDef<TData> {
const isFixed = colDef.width?.fixed != null;
const headerFn =
@@ -140,7 +140,6 @@ export function buildTanstackColumnDef<TData>(
toggleExpanded: (): void => {
row.toggleExpanded();
},
itemKey: keyData?.itemKey ?? '',
groupMeta: keyData?.groupMeta,
});
},

View File

@@ -10,6 +10,7 @@ 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,
@@ -107,8 +108,10 @@ export function getHostMetricsQueryPayload(
export { hostWidgetInfo };
export const hostGetSelectedItemExpression = (hostName: string): string =>
`host.name = ${formatValueForExpression(hostName)}`;
export const hostGetSelectedItemExpression = (
params: SelectedItemParams,
): string =>
`host.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
export function hostInitialLogTracesExpression(
host: InframonitoringtypesHostRecordDTO,

View File

@@ -52,9 +52,10 @@ import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
SelectedItemParams,
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItem,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
@@ -81,7 +82,7 @@ export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemExpression: (selectedItem: string) => string;
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
@@ -152,7 +153,9 @@ export default function K8sBaseDetails<T>({
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const entityQueryKey = useMemo(
() =>
@@ -160,8 +163,17 @@ export default function K8sBaseDetails<T>({
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
),
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
[
queryKeyPrefix,
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
selectedTime,
getAutoRefreshQueryKey,
],
);
const {
@@ -178,7 +190,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(selectedItem);
const expression = getSelectedItemExpression(selectedItemParams);
return fetchEntityData({ filter: { expression }, start, end }, signal);
},
@@ -203,8 +215,8 @@ export default function K8sBaseDetails<T>({
}, [entity, getInitialEventsExpression]);
const handleClose = useCallback((): void => {
setSelectedItem(null);
}, [setSelectedItem]);
setSelectedItemParams(null);
}, [setSelectedItemParams]);
const entityName = entity ? getEntityName(entity) : '';

View File

@@ -10,7 +10,6 @@ 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';
@@ -21,8 +20,10 @@ import {
InfraMonitoringEntity,
} from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
} from '../hooks';
import { useInfraMonitoringLineClamp } from '../components';
@@ -49,7 +50,10 @@ export type K8sBaseListEmptyStateContext = {
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> | null };
export type K8sBaseListProps<T extends K8sEntityData> = {
export type K8sBaseListProps<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
> = {
controlListPrefix?: React.ReactNode;
leftFilters?: React.ReactNode;
entity: InfraMonitoringEntity;
@@ -69,8 +73,8 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
}>;
/** 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) => string;
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
getItemKey?: (record: T) => TItemKey;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
@@ -78,7 +82,10 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
extraQueryKeyParts?: string[];
};
export function K8sBaseList<T extends K8sEntityData>({
export function K8sBaseList<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
>({
controlListPrefix,
leftFilters,
entity,
@@ -89,17 +96,16 @@ export function K8sBaseList<T extends K8sEntityData>({
eventCategory,
renderEmptyState,
extraQueryKeyParts = [],
}: K8sBaseListProps<T>): JSX.Element {
}: K8sBaseListProps<T, TItemKey>): 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 [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
@@ -226,9 +232,17 @@ export function K8sBaseList<T extends K8sEntityData>({
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
(_record: T, itemKey: TItemKey): void => {
if (groupBy.length === 0) {
void setSelectedItem(itemKey);
if (typeof itemKey === 'object' && itemKey !== null) {
setSelectedItemParams(itemKey);
} else {
setSelectedItemParams({
selectedItem: itemKey,
clusterName: null,
namespaceName: null,
});
}
}
void logEvent(InfraMonitoringEvents.ItemClicked, {
@@ -237,18 +251,43 @@ export function K8sBaseList<T extends K8sEntityData>({
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
[eventCategory, groupBy.length, setSelectedItemParams],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
(_record: T, itemKey: TItemKey): void => {
if (groupBy.length > 0) {
return;
}
// Build URL with selectedItem param
// Build URL with selectedItem params
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
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,
);
}
openInNewTab(url.pathname + url.search);
void logEvent(InfraMonitoringEvents.ItemClicked, {
@@ -274,7 +313,7 @@ export function K8sBaseList<T extends K8sEntityData>({
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
<K8sExpandedRow<T, TItemKey>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
@@ -347,7 +386,7 @@ export function K8sBaseList<T extends K8sEntityData>({
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
<TanStackTable<T, TItemKey>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}

View File

@@ -22,10 +22,11 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { InfraMonitoringEntity } from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { K8sBaseFilters } from './types';
@@ -34,7 +35,7 @@ import { buildExpressionFromGroupMeta } from './utils';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
export type K8sExpandedRowProps<T, TItemKey = string> = {
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
@@ -59,10 +60,10 @@ export type K8sExpandedRowProps<T> = {
/** 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) => string;
getItemKey?: (record: T) => TItemKey;
};
export function K8sExpandedRow<T>({
export function K8sExpandedRow<T, TItemKey = string>({
rowKey,
groupMeta,
entity,
@@ -71,13 +72,13 @@ export function K8sExpandedRow<T>({
extraQueryKeyParts = [],
getRowKey,
getItemKey,
}: K8sExpandedRowProps<T>): JSX.Element {
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const { currentQuery } = useQueryBuilder();
const parentExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
@@ -175,10 +176,18 @@ export function K8sExpandedRow<T>({
const expandedData = data?.data ?? [];
const handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
void setSelectedItem(itemKey);
(_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,
});
}
},
[setSelectedItem],
[setSelectedItemParams],
);
const handleViewAllClick = (): void => {
@@ -239,7 +248,7 @@ export function K8sExpandedRow<T>({
<div data-testid="expanded-table">
<TanStackTableStateProvider>
<TanStackTable<T>
<TanStackTable<T, TItemKey>
data={expandedData}
columns={tableColumns}
columnStorageKey={storageKey}

View File

@@ -22,6 +22,7 @@ import { openInNewTab } from 'utils/navigation';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../constants';
import { SelectedItemParams } from '../../hooks';
window.ResizeObserver =
window.ResizeObserver ||
@@ -165,11 +166,14 @@ function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function renderComponent<T extends K8sEntityData>({
function renderComponent<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
>({
queryParams,
onUrlUpdate,
...props
}: K8sBaseListProps<T> & {
}: K8sBaseListProps<T, TItemKey> & {
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
}) {
@@ -196,7 +200,7 @@ function renderComponent<T extends K8sEntityData>({
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<K8sBaseList {...props} />
<K8sBaseList<T, TItemKey> {...props} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
@@ -941,4 +945,113 @@ 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');
});
});
});

View File

@@ -0,0 +1,132 @@
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('');
});
});

View File

@@ -2,7 +2,12 @@ import { Badge } from '@signozhq/ui/badge';
import styles from './utils.module.scss';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import {
convertFiltersToExpression,
formatValueForExpression,
} from 'components/QueryBuilderV2/utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
const dotToUnder: Record<string, string> = {
'os.type': 'os_type',
@@ -89,3 +94,91 @@ 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 ');
}

View File

@@ -9,27 +9,35 @@ import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sClusterGetSelectedItemExpression = (
selectedItemId: string,
): string => `k8s.cluster.name = ${formatValueForExpression(selectedItemId)}`;
params: SelectedItemParams,
): string =>
`k8s.cluster.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesClusterRecordDTO>[] =
[{ label: 'Cluster Name', getValue: (p): string => p.clusterName || '' }];
export const k8sClusterInitialEventsExpression = (
item: InframonitoringtypesClusterRecordDTO,
): string => {
const objectName = formatValueForExpression(item.clusterName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Cluster' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'Cluster',
objectName: item.clusterName || '',
});
export const k8sClusterInitialLogTracesExpression = (
item: InframonitoringtypesClusterRecordDTO,
): string => {
const clusterName = formatValueForExpression(item.clusterName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${clusterName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
mainAttributeValue: item.clusterName,
});
export const k8sClusterGetEntityName = (
item: InframonitoringtypesClusterRecordDTO,

View File

@@ -10,6 +10,7 @@ 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,
@@ -111,7 +112,7 @@ function K8sDaemonSetsList({
);
return (
<>
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO>
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumns={k8sDaemonSetsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sDaemonSetGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
);
export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesDaemonSetRecordDTO>[] =
[
@@ -37,12 +45,23 @@ export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframo
export const k8sDaemonSetInitialEventsExpression = (
item: InframonitoringtypesDaemonSetRecordDTO,
): string =>
`${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] ?? '')}`;
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],
});
export const k8sDaemonSetInitialLogTracesExpression = (
item: InframonitoringtypesDaemonSetRecordDTO,
): string =>
`${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] ?? '')}`;
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],
});
export const k8sDaemonSetGetEntityName = (
item: InframonitoringtypesDaemonSetRecordDTO,
@@ -115,6 +134,40 @@ 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',
@@ -148,19 +201,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -202,19 +243,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -256,19 +285,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -344,19 +361,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -398,19 +403,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -452,19 +445,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -540,19 +521,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -641,19 +610,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
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] ??
'',
},
...filters,
],
op: 'AND',
},

View File

@@ -6,6 +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, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -31,8 +32,15 @@ export function getK8sDaemonSetRowKey(
export function getK8sDaemonSetItemKey(
daemonSet: InframonitoringtypesDaemonSetRecordDTO,
): string {
return daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '';
): 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,
};
}
export type DaemonSetTableColumnConfig =

View File

@@ -11,6 +11,7 @@ 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,
@@ -117,7 +118,7 @@ function K8sDeploymentsList({
return (
<>
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO>
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumns={k8sDeploymentsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sDeploymentGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
);
export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesDeploymentRecordDTO>[] =
[
@@ -36,24 +44,24 @@ export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<Infram
export const k8sDeploymentInitialEventsExpression = (
item: InframonitoringtypesDeploymentRecordDTO,
): 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}`;
};
): 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],
});
export const k8sDeploymentInitialLogTracesExpression = (
item: InframonitoringtypesDeploymentRecordDTO,
): 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}`;
};
): 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],
});
export const k8sDeploymentGetEntityName = (
item: InframonitoringtypesDeploymentRecordDTO,
@@ -121,6 +129,43 @@ 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 [
{
@@ -155,6 +200,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -196,6 +242,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -237,6 +284,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -312,6 +360,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -353,6 +402,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -394,6 +444,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -469,6 +520,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -557,6 +609,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},

View File

@@ -6,6 +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, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -31,8 +32,15 @@ export function getK8sDeploymentRowKey(
export function getK8sDeploymentItemKey(
deployment: InframonitoringtypesDeploymentRecordDTO,
): string {
return deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '';
): 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,
};
}
export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDeploymentRecordDTO>[] =

View File

@@ -44,7 +44,7 @@ import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItem,
useInfraMonitoringSelectedItemParams,
} from './hooks';
import K8sJobsList from './Jobs/K8sJobsList';
import K8sNamespacesList from './Namespaces/K8sNamespacesList';
@@ -63,7 +63,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -179,7 +179,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setSelectedItem(null);
setSelectedItemParams(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {

View File

@@ -11,6 +11,7 @@ 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 {
getJobMetricsQueryPayload,
jobWidgetInfo,
@@ -117,7 +118,7 @@ function K8sJobsList({
return (
<>
<K8sBaseList<InframonitoringtypesJobRecordDTO>
<K8sBaseList<InframonitoringtypesJobRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.JOBS}
tableColumns={k8sJobsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sJobGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
);
export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesJobRecordDTO>[] =
[
@@ -36,24 +44,23 @@ export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonitori
export const k8sJobInitialEventsExpression = (
item: InframonitoringtypesJobRecordDTO,
): string => {
const name = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Job' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${name}`;
};
): string =>
buildEventsExpression({
objectKind: 'Job',
objectName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sJobInitialLogTracesExpression = (
item: InframonitoringtypesJobRecordDTO,
): string => {
const jobName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? '',
);
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME} = ${jobName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
mainAttributeValue: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME],
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sJobGetEntityName = (
item: InframonitoringtypesJobRecordDTO,
@@ -97,10 +104,54 @@ export const getJobMetricsQueryPayload = (
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sJobNameKey = dotMetricsEnabled ? 'k8s.job.name' : 'k8s_job_name';
const k8sClusterNameKey = dotMetricsEnabled
? 'k8s.cluster.name'
: 'k8s_cluster_name';
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
const clusterName =
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const namespaceName =
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
const filters = [
{
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
{
id: 'f3',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: namespaceName,
},
];
return [
{
selectedTime: 'GLOBAL_TIME',
@@ -120,31 +171,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '6b59b690',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],
@@ -205,31 +232,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '8c217f4d',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],
@@ -290,31 +293,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '2bbf9d0c',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],
@@ -388,31 +367,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '448e6cf7',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],

View File

@@ -6,6 +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, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -27,8 +28,13 @@ export function getK8sJobRowKey(job: InframonitoringtypesJobRecordDTO): string {
export function getK8sJobItemKey(
job: InframonitoringtypesJobRecordDTO,
): string {
return job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '';
): SelectedItemParams {
return {
selectedItem: job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? null,
clusterName: job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export type JobTableColumnConfig =

View File

@@ -11,6 +11,7 @@ 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 {
getNamespaceMetricsQueryPayload,
k8sNamespaceDetailsMetadataConfig,
@@ -117,7 +118,7 @@ function K8sNamespacesList({
return (
<>
<K8sBaseList<InframonitoringtypesNamespaceRecordDTO>
<K8sBaseList<InframonitoringtypesNamespaceRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NAMESPACES}
tableColumns={k8sNamespacesColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sNamespaceGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
);
export const k8sNamespaceDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesNamespaceRecordDTO>[] =
[
@@ -27,17 +35,21 @@ export const k8sNamespaceDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframo
export const k8sNamespaceInitialEventsExpression = (
item: InframonitoringtypesNamespaceRecordDTO,
): string => {
const name = formatValueForExpression(item.namespaceName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Namespace' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${name}`;
};
): string =>
buildEventsExpression({
objectKind: 'Namespace',
objectName: item.namespaceName || '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNamespaceInitialLogTracesExpression = (
item: InframonitoringtypesNamespaceRecordDTO,
): string => {
const name = formatValueForExpression(item.namespaceName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${name}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
mainAttributeValue: item.namespaceName,
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNamespaceGetEntityName = (
item: InframonitoringtypesNamespaceRecordDTO,
@@ -179,6 +191,24 @@ export const getNamespaceMetricsQueryPayload = (
'k8s.deployment.name',
'k8s_deployment_name',
);
const k8sClusterNameKey = getKey('k8s.cluster.name', 'k8s_cluster_name');
const clusterName =
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const filters = [
{
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
];
return [
{
@@ -211,6 +241,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -250,6 +281,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -289,6 +321,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -328,6 +361,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -401,6 +435,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -440,6 +475,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -479,6 +515,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -518,6 +555,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -557,6 +595,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -596,6 +635,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -669,6 +709,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -749,6 +790,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -829,6 +871,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -915,6 +958,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1001,6 +1045,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1047,6 +1092,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1093,6 +1139,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1173,6 +1220,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1225,6 +1273,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1311,6 +1360,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1357,6 +1407,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1403,6 +1454,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1449,6 +1501,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1529,6 +1582,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1575,6 +1629,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},

View File

@@ -16,6 +16,7 @@ import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { SelectedItemParams } from '../hooks';
export function getK8sNamespaceRowKey(
namespace: InframonitoringtypesNamespaceRecordDTO,
@@ -29,8 +30,15 @@ export function getK8sNamespaceRowKey(
export function getK8sNamespaceItemKey(
namespace: InframonitoringtypesNamespaceRecordDTO,
): string {
return namespace.namespaceName;
): SelectedItemParams {
return {
selectedItem:
namespace.namespaceName ??
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
null,
clusterName:
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
};
}
export type NamespaceTableColumnConfig =

View File

@@ -7,13 +7,19 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
export const k8sNodeGetSelectedItemExpression = (
selectedItemId: string,
): string => `k8s.node.name = ${formatValueForExpression(selectedItemId)}`;
params: SelectedItemParams,
): string =>
`k8s.node.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
export const k8sNodeDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesNodeRecordDTO>[] =
[
@@ -28,12 +34,20 @@ export const k8sNodeDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonitor
export const k8sNodeInitialEventsExpression = (
item: InframonitoringtypesNodeRecordDTO,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Node' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${formatValueForExpression(item.nodeName || '')}`;
buildEventsExpression({
objectKind: 'Node',
objectName: item.nodeName || '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNodeInitialLogTracesExpression = (
item: InframonitoringtypesNodeRecordDTO,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME} = ${formatValueForExpression(item.nodeName || '')} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '')}`;
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
mainAttributeValue: item.nodeName,
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNodeGetEntityName = (
item: InframonitoringtypesNodeRecordDTO,

View File

@@ -8,11 +8,17 @@ import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
export const k8sPodGetSelectedItemExpression = (
selectedItemId: string,
): string => `k8s.pod.uid = ${formatValueForExpression(selectedItemId)}`;
params: SelectedItemParams,
): string =>
`k8s.pod.uid = ${formatValueForExpression(params.selectedItem ?? '')}`;
export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesPodRecordDTO>[] =
[
@@ -35,21 +41,23 @@ export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonitori
export const k8sPodInitialEventsExpression = (
pod: InframonitoringtypesPodRecordDTO,
): string => {
const podName = formatValueForExpression(
pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Pod' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${podName}`;
};
): string =>
buildEventsExpression({
objectKind: 'Pod',
objectName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
clusterName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sPodInitialLogTracesExpression = (
pod: InframonitoringtypesPodRecordDTO,
): string => {
const podName = formatValueForExpression(
pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME} = ${podName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
mainAttributeValue: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME],
clusterName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sPodGetEntityName = (
pod: InframonitoringtypesPodRecordDTO,

View File

@@ -11,6 +11,7 @@ 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 {
getStatefulSetMetricsQueryPayload,
k8sStatefulSetDetailsMetadataConfig,
@@ -117,7 +118,7 @@ function K8sStatefulSetsList({
return (
<>
<K8sBaseList<InframonitoringtypesStatefulSetRecordDTO>
<K8sBaseList<InframonitoringtypesStatefulSetRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.STATEFULSETS}
tableColumns={k8sStatefulSetsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sStatefulSetGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
);
export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesStatefulSetRecordDTO>[] =
[
@@ -22,6 +30,11 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Infra
getValue: (p): string =>
p.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
},
{
label: 'Cluster Name',
getValue: (p): string =>
p.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
},
{
label: 'Namespace Name',
getValue: (p): string =>
@@ -31,24 +44,25 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Infra
export const k8sStatefulSetInitialEventsExpression = (
item: InframonitoringtypesStatefulSetRecordDTO,
): string => {
const objectName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'StatefulSet' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'StatefulSet',
objectName:
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sStatefulSetInitialLogTracesExpression = (
item: InframonitoringtypesStatefulSetRecordDTO,
): string => {
const statefulSetName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
);
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME} = ${statefulSetName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
mainAttributeValue:
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME],
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sStatefulSetGetEntityName = (
item: InframonitoringtypesStatefulSetRecordDTO,
@@ -93,7 +107,42 @@ export const getStatefulSetMetricsQueryPayload = (
const k8sNamespaceNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME
: 'k8s_namespace_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
const k8sPodNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME
: 'k8s_pod_name';
const k8sClusterNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME
: 'k8s_cluster_name';
const clusterName =
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const namespaceName =
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
const filters = [
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: namespaceName,
},
{
id: 'f3',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
];
const k8sPodCpuUtilKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
@@ -168,19 +217,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -223,19 +260,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -278,19 +303,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -353,19 +366,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -408,19 +409,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -483,19 +472,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -525,7 +502,7 @@ export const getStatefulSetMetricsQueryPayload = (
filters: {
items: [
{
id: 'f3',
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'pod_name',
@@ -538,19 +515,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -593,19 +558,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -668,19 +621,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -723,19 +664,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -798,19 +727,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -886,19 +803,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},

View File

@@ -6,6 +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, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -31,10 +32,15 @@ export function getK8sStatefulSetRowKey(
export function getK8sStatefulSetItemKey(
statefulSet: InframonitoringtypesStatefulSetRecordDTO,
): string {
return (
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || ''
);
): SelectedItemParams {
return {
selectedItem:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? null,
clusterName:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesStatefulSetRecordDTO>[] =

View File

@@ -11,6 +11,7 @@ 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 {
getVolumeMetricsQueryPayload,
k8sVolumeDetailsMetadataConfig,
@@ -117,7 +118,7 @@ function K8sVolumesList({
return (
<>
<K8sBaseList<InframonitoringtypesVolumeRecordDTO>
<K8sBaseList<InframonitoringtypesVolumeRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.VOLUMES}
tableColumns={k8sVolumesColumnsConfig}

View File

@@ -6,15 +6,22 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sVolumeGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
);
export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesVolumeRecordDTO>[] =
[
@@ -36,22 +43,23 @@ export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonit
export const k8sVolumeInitialEventsExpression = (
item: InframonitoringtypesVolumeRecordDTO,
): string => {
const objectName = formatValueForExpression(
item.persistentVolumeClaimName || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'PersistentVolumeClaim' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'PersistentVolumeClaim',
objectName: item.persistentVolumeClaimName || '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sVolumeInitialLogTracesExpression = (
item: InframonitoringtypesVolumeRecordDTO,
): string => {
const pvcName = formatValueForExpression(item.persistentVolumeClaimName || '');
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME} = ${pvcName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
mainAttributeValue: item.persistentVolumeClaimName,
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sVolumeGetEntityName = (
item: InframonitoringtypesVolumeRecordDTO,

View File

@@ -1,6 +1,5 @@
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { InframonitoringtypesVolumeRecordDTO } from 'api/generated/services/sigNoz.schemas';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
@@ -12,6 +11,7 @@ import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import { HardDrive } from '@signozhq/icons';
export function getK8sVolumeRowKey(
@@ -26,8 +26,17 @@ export function getK8sVolumeRowKey(
export function getK8sVolumeItemKey(
volume: InframonitoringtypesVolumeRecordDTO,
): string {
return volume.persistentVolumeClaimName;
): SelectedItemParams {
return {
selectedItem:
volume.persistentVolumeClaimName ??
volume.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME] ??
null,
clusterName:
volume.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
volume.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export type VolumeTableColumnConfig =

View File

@@ -897,6 +897,8 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
PAGE_SIZE: 'pageSize',
EXPANDED: 'expanded',
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
};
/** Metric namespace prefixes for /fields/keys and /fields/values APIs */

View File

@@ -5,8 +5,10 @@ import {
parseAsJson,
parseAsString,
useQueryState,
useQueryStates,
UseQueryStateReturn,
} from 'nuqs';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
TagFilter,
@@ -128,16 +130,70 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringSelectedItem = (): UseQueryStateReturn<
string,
string | undefined
> => {
return useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
parseAsString,
);
export interface SelectedItemParams {
selectedItem: string | null;
clusterName?: string | null;
namespaceName?: string | null;
}
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
};
export type UseSelectedItemParamsReturn = [
SelectedItemParams,
(params: SelectedItemParams | null) => void,
];
export const useInfraMonitoringSelectedItemParams =
(): UseSelectedItemParamsReturn => {
const [rawParams, setRawParams] = useQueryStates(
selectedItemParamsParsers,
defaultNuqsOptions,
);
const params: SelectedItemParams = useMemo(
() => ({
selectedItem:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
clusterName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
null,
namespaceName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
null,
}),
[rawParams],
);
const setParams = useCallback(
(newParams: Partial<SelectedItemParams> | null): void => {
if (newParams === null) {
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
});
return;
}
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
newParams.selectedItem ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
newParams.namespaceName ?? null,
});
},
[setRawParams],
);
return [params, setParams];
};
export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
string,
string

View File

@@ -30,7 +30,7 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
group_id: 'group-1',
groupId: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
@@ -89,5 +89,5 @@ export const mockGroups: MapperGroup[] = [
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
makeMapper({ id: 'mapper-1', groupId: 'group-1' }),
];

View File

@@ -11,6 +11,7 @@ import {
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -152,6 +153,11 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}

View File

@@ -19,6 +19,7 @@ interface ExportOptions {
interface UseExportRawDataProps {
dataSource: DataSource;
panelType?: PANEL_TYPES;
}
interface UseExportRawDataReturn {
@@ -28,6 +29,7 @@ interface UseExportRawDataReturn {
export function useExportRawData({
dataSource,
panelType = PANEL_TYPES.LIST,
}: UseExportRawDataProps): UseExportRawDataReturn {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -83,7 +85,7 @@ export function useExportRawData({
const { queryPayload } = prepareQueryRangePayloadV5({
query: exportQuery,
graphType: PANEL_TYPES.LIST,
graphType: panelType,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
});
@@ -96,7 +98,7 @@ export function useExportRawData({
setIsDownloading(false);
}
},
[stagedQuery, globalSelectedInterval, dataSource],
[stagedQuery, globalSelectedInterval, dataSource, panelType],
);
return { isDownloading, handleExportRawData };

View File

@@ -3,7 +3,6 @@ import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
containsAnyVariableReference,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
@@ -449,25 +448,3 @@ describe('getVariableReferencesInQuery', () => {
expect(getVariableReferencesInQuery(query, [])).toStrictEqual([]);
});
});
describe('containsAnyVariableReference', () => {
it.each([
['SELECT count() FROM t WHERE service = $service.name', true],
['up{env="$deployment_environment"}', true],
['{{.service_name}}', true],
['{{ service_name }}', true],
['[[service_name]]', true],
['$_private', true],
])('detects a reference in %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
it.each([
['SELECT count() FROM t WHERE x = 1', false],
['rate(http_requests[$__interval])', false],
['SELECT $1 FROM t', false],
['', false],
])('does not falsely match %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
});

View File

@@ -1,4 +1,4 @@
import { escapeRegExp, isArray } from 'lodash-es';
import { isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -33,23 +33,6 @@ export function textContainsVariableReference(
return buildVariableReferencePattern(variableName).test(text);
}
/**
* Matches *any* variable reference in a recognized syntax without knowing the
* name: `{{name}}`, `{{.name}}`, `[[name]]`, or `$name`. The `$` form excludes
* `$__…` macros and positional `$1` so built-ins don't read as variables.
*/
const ANY_VARIABLE_REFERENCE =
/\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$(?!__)[a-zA-Z_][\w.]*/;
/**
* Returns true if `text` contains a reference to any variable. Use when the set
* of variable names isn't known yet (e.g. before the fetch context initializes),
* so a name-based {@link textContainsVariableReference} check can't run.
*/
export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
@@ -151,52 +134,3 @@ export function getVariableReferencesInQuery(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Rewrites every reference to `oldName` in `text` to `newName`, preserving the
* surrounding syntax for each recognized form ({{.x}}, {{x}}, $x, [[x]]). Used
* when a variable is renamed so its usages across queries stay valid.
*/
export function rewriteVariableReferences(
text: string,
oldName: string,
newName: string,
): string {
if (!text || !oldName || oldName === newName) {
return text;
}
const name = escapeRegExp(oldName);
return text
.replace(
new RegExp(`(\\{\\{\\s*?\\.)${name}(\\s*?\\}\\})`, 'g'),
`$1${newName}$2`,
)
.replace(new RegExp(`(\\{\\{\\s*)${name}(\\s*\\}\\})`, 'g'), `$1${newName}$2`)
.replace(new RegExp(`\\$${name}\\b`, 'g'), `$${newName}`)
.replace(
new RegExp(`(\\[\\[\\s*)${name}(\\s*\\]\\])`, 'g'),
`$1${newName}$2`,
);
}
/**
* Best-effort removal of the clause that references `variableName` from an
* ` AND `-joined filter expression (e.g. a builder query's `filter.expression`).
* Any top-level `AND` part that references the variable is dropped. It does not
* understand `OR`/nested parentheses, so it is a starting point the user reviews
* before applying — never an automatic edit of raw PromQL/ClickHouse.
*/
export function removeVariableReferenceClause(
expression: string,
variableName: string,
): string {
if (!expression) {
return expression;
}
return expression
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean)
.filter((part) => !textContainsVariableReference(part, variableName))
.join(' AND ');
}

View File

@@ -0,0 +1,94 @@
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { exportScalarData } from '../exportScalarData';
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [
{ key: 'service.name', dataType: 'string', type: 'tag', id: 'svc' },
],
legend: '',
},
],
queryFormulas: [],
},
} as unknown as Query;
function makeResponse(
tables: {
queryName: string;
columns: { name: string; id?: string; isValueColumn: boolean }[];
rows: Record<string, string | number>[];
}[],
): SuccessResponse<MetricRangePayloadProps> {
return {
statusCode: 200,
error: null,
message: '',
payload: {
data: {
resultType: 'scalar',
result: tables.map((table) => ({
queryName: table.queryName,
legend: '',
series: null,
list: null,
table: {
columns: table.columns.map((col) => ({
...col,
queryName: table.queryName,
})),
rows: table.rows.map((row) => ({ data: row })),
},
})),
},
},
} as unknown as SuccessResponse<MetricRangePayloadProps>;
}
describe('exportScalarData', () => {
it('serializes the table exactly as QueryTable prepares it', () => {
const data = makeResponse([
{
queryName: 'A',
columns: [
{ name: 'service.name', id: 'service.name', isValueColumn: false },
{ name: 'count()', id: 'A', isValueColumn: true },
],
rows: [
{ 'service.name': 'frontend', A: 120 },
{ 'service.name': 'cart', A: 80 },
],
},
]);
const table = exportScalarData({ data, query });
// group + aggregation columns, raw values, on-screen order — inherited
// 1:1 from createTableColumnsFromQuery (the renderer's own preparer)
expect(table).toStrictEqual({
headers: ['service.name', 'count()'],
rows: [
['frontend', 120],
['cart', 80],
],
});
});
it('returns an empty table for an empty response', () => {
const table = exportScalarData({
data: makeResponse([]),
query,
});
expect(table.rows).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,58 @@
import { exportTableData } from '../exportTableData';
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'count()', key: 'A', isValueColumn: true },
{ name: 'avg(duration)', key: 'B', isValueColumn: true },
];
describe('exportTableData', () => {
it('serializes raw values in display column order', () => {
const table = exportTableData({
columns,
dataSource: [
{ 'service.name': 'frontend', A: 120, B: 45.5 },
{ 'service.name': 'cart', A: 80, B: 12 },
],
});
expect(table).toStrictEqual({
headers: ['service.name', 'count()', 'avg(duration)'],
rows: [
['frontend', 120, 45.5],
['cart', 80, 12],
],
});
});
it('appends column units to value columns only, skipping display-only ids', () => {
const table = exportTableData({
columns,
dataSource: [{ 'service.name': 'frontend', A: 120, B: 45.5 }],
columnUnits: { A: 'short', B: 'ms', 'service.name': 'ms' },
});
// group column never gets a unit; 'short' is display-only and skipped
expect(table.headers).toStrictEqual([
'service.name',
'count()',
'avg(duration) (ms)',
]);
});
it('marks missing cells as blank gaps', () => {
const table = exportTableData({
columns,
dataSource: [{ 'service.name': 'frontend', A: 120 }],
});
expect(table.rows).toStrictEqual([['frontend', 120, '']]);
});
it('returns a headers-only table for empty data', () => {
expect(exportTableData({ columns, dataSource: [] })).toStrictEqual({
headers: ['service.name', 'count()', 'avg(duration)'],
rows: [],
});
});
});

View File

@@ -0,0 +1,48 @@
import { createTableColumnsFromQuery } from 'lib/query/createTableColumnsFromQuery';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { exportTableData } from './exportTableData';
import { SerializedTable } from './types';
interface ExportScalarDataArgs {
// The queryRange response object the table mount already holds (the
// formatForWeb payload carrying webTables).
data?: SuccessResponse<MetricRangePayloadProps>;
query: Query;
}
/**
* Serializes a scalar/table queryRange response into a table — via
* createTableColumnsFromQuery, the exact preparer QueryTable renders from, so
* the export inherits the on-screen merge, naming and column order 1:1.
*/
export function exportScalarData({
data,
query,
}: ExportScalarDataArgs): SerializedTable {
const queryTableData = (data?.payload?.data?.newResult?.data?.result ||
data?.payload?.data?.result ||
[]) as QueryDataV3[];
const { columns, dataSource } = createTableColumnsFromQuery({
query,
queryTableData,
});
return exportTableData({
// antd widens title/dataIndex; createTableColumnsFromQuery always sets strings
columns: columns.map((column) => {
const rawIndex = 'dataIndex' in column ? column.dataIndex : undefined;
const key =
typeof rawIndex === 'string' || typeof rawIndex === 'number'
? String(rawIndex)
: '';
const name = typeof column.title === 'string' ? column.title : key;
return { name, key: key || name };
}),
dataSource: dataSource as unknown as Record<string, unknown>[],
});
}

View File

@@ -0,0 +1,46 @@
import { SerializedTable } from './types';
import { withUnit } from './withUnit';
/** Generic table-model column — any prepared table (QueryTable, dashboard
* tables, plain antd tables) adapts to this in a line or two. */
export interface ExportTableColumn {
/** Display name, used as the export header (column order = array order). */
name: string;
/** Key into each dataSource record. */
key: string;
isValueColumn?: boolean;
}
interface ExportTableDataArgs {
columns: ExportTableColumn[];
dataSource: Record<string, unknown>[];
/** Per-column display unit, keyed by column key (dashboards; absent in explorer). */
columnUnits?: Record<string, string>;
}
/**
* Serializes a prepared table model into a format-agnostic table — raw values
* in display column order (lossless; no cell formatting applied).
*/
export function exportTableData({
columns,
dataSource,
columnUnits,
}: ExportTableDataArgs): SerializedTable {
const headers = columns.map((column) =>
column.isValueColumn
? withUnit(column.name, columnUnits?.[column.key])
: column.name,
);
const rows = dataSource.map((record) =>
columns.map((column) => {
const value = record[column.key];
return value === undefined || value === null
? ''
: (value as string | number);
}),
);
return { headers, rows };
}

View File

@@ -5,6 +5,7 @@ import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { SerializedTable } from './types';
import { withUnit } from './withUnit';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
@@ -98,18 +99,6 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}

View File

@@ -0,0 +1,11 @@
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
/** Appends a unit to a header: `value` → `value (ms)`. Skips display-only ids. */
export function withUnit(header: string, unit?: string): string {
if (!unit || DISPLAY_ONLY_UNITS.has(unit)) {
return header;
}
return `${header} (${unit})`;
}

View File

@@ -1,86 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.intro {
color: var(--l2-foreground);
font-size: 13px;
line-height: 1.5;
}
.rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sourceLabel {
color: var(--l1-foreground);
font-weight: 600;
font-size: 13px;
}
.kindTag {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
white-space: nowrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.textArea {
font-family: var(--font-mono, monospace);
font-size: 12px;
}
.disabled {
opacity: 0.6;
}
.warning {
color: var(--warning-foreground, #d97706);
font-size: 11px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -1,159 +0,0 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea + Checkbox have no @signozhq/ui equivalent yet
import { Checkbox, Input as AntdInput } from 'antd';
import cx from 'classnames';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableImpactMode, VariableUsage } from '../variableUsages';
import { useVariableImpactState } from './useVariableImpactState';
import styles from './VariableImpactDialog.module.scss';
const KIND_LABEL: Record<VariableUsage['kind'], string> = {
builder: 'Query builder',
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
};
interface VariableImpactDialogProps {
open: boolean;
mode: VariableImpactMode;
/** The variable being renamed/deleted (its current name). */
variableName: string;
/** The new name (rename mode only). */
newName?: string;
usages: VariableUsage[];
isLoading: boolean;
onConfirm: (resolvedUsages: VariableUsage[]) => void;
onClose: () => void;
}
/**
* Blocks a rename/delete of a referenced variable behind a review step: lists
* every usage across panel queries (builder / PromQL / ClickHouse) and other
* variables, shows the current vs resulting query, and lets the user edit each
* result or exclude it before applying.
*/
function VariableImpactDialog({
open,
mode,
variableName,
newName,
usages,
isLoading,
onConfirm,
onClose,
}: VariableImpactDialogProps): JSX.Element {
const { rows, setResultingText, toggleIncluded, resolvedUsages } =
useVariableImpactState(usages, open);
const isRename = mode === 'rename';
const count = usages.length;
const plural = count === 1 ? '' : 's';
const intro = isRename
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
const footer = (
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="variable-impact-cancel"
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color={isRename ? 'primary' : 'destructive'}
loading={isLoading}
onClick={(): void => onConfirm(resolvedUsages)}
testId="variable-impact-confirm"
>
<Check size={12} />
{isRename ? 'Rename' : 'Delete'}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
width="wide"
showCloseButton={false}
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
style={{ zIndex: 1100 }}
showOverlay={false}
footer={footer}
>
<div className={styles.body}>
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
<div className={styles.rows}>
{rows.map((row) => {
const stillReferences =
row.included &&
textContainsVariableReference(row.resultingText, variableName);
return (
<div
key={row.id}
className={styles.row}
data-testid={`variable-impact-row-${row.id}`}
>
<div className={styles.rowHeader}>
<Checkbox
checked={row.included}
onChange={(): void => toggleIncluded(row.id)}
data-testid={`variable-impact-include-${row.id}`}
>
<span className={styles.sourceLabel}>{row.sourceLabel}</span>
</Checkbox>
<span className={styles.kindTag}>{KIND_LABEL[row.kind]}</span>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>
Current
</Typography.Text>
<AntdInput.TextArea
className={styles.textArea}
value={row.currentText}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Result</Typography.Text>
<AntdInput.TextArea
className={cx(styles.textArea, !row.included && styles.disabled)}
value={row.resultingText}
disabled={!row.included}
autoSize={{ minRows: 1, maxRows: 4 }}
onChange={(e): void => setResultingText(row.id, e.target.value)}
data-testid={`variable-impact-result-${row.id}`}
/>
{stillReferences ? (
<Typography.Text className={styles.warning}>
Still references ${variableName}
</Typography.Text>
) : null}
</div>
</div>
);
})}
</div>
</div>
</DialogWrapper>
);
}
export default VariableImpactDialog;

View File

@@ -1,52 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { VariableUsage } from '../variableUsages';
/** A usage row plus whether its edit will be applied on confirm. */
export interface EditableVariableUsage extends VariableUsage {
included: boolean;
}
interface UseVariableImpactState {
rows: EditableVariableUsage[];
setResultingText: (id: string, text: string) => void;
toggleIncluded: (id: string) => void;
/** The included rows, as plain usages, to build the patch from. */
resolvedUsages: VariableUsage[];
}
/**
* Editable state for the impact dialog: a per-usage copy the user can edit
* (`resultingText`) and include/exclude before applying. Resets whenever the
* dialog (re)opens for a fresh usage set.
*/
export function useVariableImpactState(
usages: VariableUsage[],
open: boolean,
): UseVariableImpactState {
const [rows, setRows] = useState<EditableVariableUsage[]>([]);
useEffect(() => {
if (open) {
setRows(usages.map((usage) => ({ ...usage, included: true })));
}
}, [open, usages]);
const setResultingText = useCallback((id: string, text: string): void => {
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, resultingText: text } : row)),
);
}, []);
const toggleIncluded = useCallback((id: string): void => {
setRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, included: !row.included } : row,
),
);
}, []);
const resolvedUsages: VariableUsage[] = rows.filter((row) => row.included);
return { rows, setResultingText, toggleIncluded, resolvedUsages };
}

View File

@@ -1,98 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { findVariableUsages } from '../variableUsages';
// Identity adapter so `spec.variables` can be plain form models in the test.
jest.mock('../variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function variable(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function builderPanel(name: string, expression: string): unknown {
return {
spec: {
display: { name },
queries: [
{
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
},
},
],
},
};
}
function promqlPanel(name: string, query: string): unknown {
return {
spec: {
display: { name },
queries: [
{ spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { query } } } },
],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
): DashboardtypesGettableDashboardV2DTO {
return {
spec: { panels, variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('findVariableUsages', () => {
const dash = dashboard(
{
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
p3: builderPanel('Unrelated', "env = 'prod'"),
},
[
variable({ name: 'svc', type: 'QUERY' }),
variable({
name: 'other',
type: 'QUERY',
queryValue: 'SELECT x WHERE s = $svc',
}),
variable({ name: 'plain', type: 'QUERY', queryValue: 'SELECT y' }),
],
);
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const ids = usages.map((u) => u.id).sort();
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
});
it('rewrites references for a rename across all kinds', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
});
it('strips builder clauses on delete but leaves raw/variable queries for review', () => {
const usages = findVariableUsages(dash, 'svc', 'delete');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
// Builder: the clause referencing $svc is dropped.
expect(byId['panel:p1:0']).toBe("env = 'prod'");
// Raw PromQL + variable query: unchanged (user edits).
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
});
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
});

View File

@@ -17,17 +17,7 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
import {
applyVariableQueryEdits,
buildVariableImpactPatch,
} from './variableImpactPatch';
import {
findVariableUsages,
type VariableImpactMode,
type VariableUsage,
} from './variableUsages';
import VariableForm from './VariableForm/VariableForm';
import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
@@ -69,16 +59,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
// A pending rename/delete that touches other queries — resolved via the impact
// dialog before it is applied. `nextVariables` is the array to persist (with the
// rename/delete already applied), before any variable-query edits.
const [impact, setImpact] = useState<{
mode: VariableImpactMode;
variableName: string;
newName?: string;
usages: VariableUsage[];
nextVariables: VariableFormModel[];
} | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -124,38 +104,12 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const editingIndex = isEditing?.type === 'edit' ? isEditing.index : null;
const oldName = editingIndex !== null ? variables[editingIndex].name : null;
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (editingIndex !== null) {
next[editingIndex] = formModel;
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
}
// A rename that other queries/variables reference must be reviewed first, so
// the references are rewritten alongside the rename (never left dangling).
if (oldName && oldName !== formModel.name) {
const usages = findVariableUsages(
dashboard,
oldName,
'rename',
formModel.name,
);
if (usages.length > 0) {
setIsEditing(null);
setImpact({
mode: 'rename',
variableName: oldName,
newName: formModel.name,
usages,
nextVariables: next,
});
return;
}
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
@@ -195,57 +149,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
// Delete requested from the list: if the variable is referenced anywhere, block
// and open the impact dialog; otherwise fall through to the simple confirm.
const requestDelete = (index: number): void => {
const usages = findVariableUsages(dashboard, variables[index].name, 'delete');
if (usages.length > 0) {
setImpact({
mode: 'delete',
variableName: variables[index].name,
usages,
nextVariables: variables.filter((_, i) => i !== index),
});
return;
}
setConfirmDeleteIndex(index);
};
// Applies a resolved rename/delete: the variables array (rename/delete + edited
// variable queries) and each touched panel's queries, in one atomic patch.
const handleImpactConfirm = async (
resolvedUsages: VariableUsage[],
): Promise<void> => {
if (!impact) {
return;
}
const nextVariables = applyVariableQueryEdits(
impact.nextVariables,
resolvedUsages,
);
const ops = buildVariableImpactPatch(
dashboard,
nextVariables,
resolvedUsages,
);
setVariables(nextVariables);
try {
await patchAsync(ops);
toast.success(
impact.mode === 'rename'
? `Renamed to $${impact.newName}`
: `Deleted $${impact.variableName}`,
);
} catch {
toast.error(
impact.mode === 'rename'
? 'Could not rename the variable'
: 'Could not delete the variable',
);
}
setImpact(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
@@ -299,7 +202,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={requestDelete}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
@@ -317,16 +220,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
<VariableImpactDialog
open={impact !== null}
mode={impact?.mode ?? 'delete'}
variableName={impact?.variableName ?? ''}
newName={impact?.newName}
usages={impact?.usages ?? []}
isLoading={isPatching}
onConfirm={(resolved): void => void handleImpactConfirm(resolved)}
onClose={(): void => setImpact(null)}
/>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
import type { VariableUsage, VariableUsageKind } from './variableUsages';
/** Minimal writable view of an envelope spec's reference-bearing fields. */
interface WritableSpec {
query?: string;
filter?: { expression?: string };
}
/** Writes the resolved text into the spec's builder filter or raw query field. */
function writeSpecText(
spec: WritableSpec,
kind: VariableUsageKind,
text: string,
): void {
if (kind === 'builder') {
spec.filter = { ...(spec.filter ?? {}), expression: text };
} else {
spec.query = text;
}
}
/** Applies one panel usage's edited text into a (cloned) queries array in place. */
function applyPanelUsage(
queries: DashboardtypesQueryDTO[],
usage: VariableUsage,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
const envelope = (composite.queries ?? [])[usage.envelopeIndex];
if (envelope?.spec) {
writeSpecText(
envelope.spec as WritableSpec,
usage.kind,
usage.resultingText,
);
}
} else {
// Bare BuilderQuery / PromQLQuery / ClickHouseSQL — the plugin spec is the
// single envelope (index 0).
writeSpecText(plugin.spec as WritableSpec, usage.kind, usage.resultingText);
}
}
/**
* Applies the variable-definition usages' edited text back into the matching
* variable's `queryValue`, so a renamed/deleted variable's references inside
* another query variable are updated alongside the panels.
*/
export function applyVariableQueryEdits(
variables: VariableFormModel[],
usages: VariableUsage[],
): VariableFormModel[] {
const edits = new Map(
usages
.filter((usage) => usage.sourceType === 'variable')
.map((usage) => [usage.sourceId, usage.resultingText]),
);
if (edits.size === 0) {
return variables;
}
return variables.map((variable) =>
edits.has(variable.name)
? { ...variable, queryValue: edits.get(variable.name) as string }
: variable,
);
}
/**
* Builds the atomic JSON-Patch for a variable rename/delete impact: replaces the
* whole variables array (which the caller has already updated for the rename/
* delete and any variable-query edits) and replaces each touched panel's queries
* with the user's resolved text.
*/
export function buildVariableImpactPatch(
dashboard: DashboardtypesGettableDashboardV2DTO,
nextVariables: VariableFormModel[],
usages: VariableUsage[],
): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
...buildVariablesPatch(nextVariables.map(formModelToDto)),
];
const panels = dashboard.spec.panels ?? {};
const byPanel = new Map<string, VariableUsage[]>();
usages
.filter((usage) => usage.sourceType === 'panel')
.forEach((usage) => {
const list = byPanel.get(usage.sourceId) ?? [];
list.push(usage);
byPanel.set(usage.sourceId, list);
});
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
list.forEach((usage) => applyPanelUsage(queries, usage));
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
});
});
return ops;
}

View File

@@ -1,163 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
removeVariableReferenceClause,
rewriteVariableReferences,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { dtoToFormModel } from './variableAdapters';
/** The kind of query text a variable is referenced from. */
export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
export type VariableImpactMode = 'rename' | 'delete';
/**
* One place a variable is referenced — a panel query's builder filter expression,
* a PromQL/ClickHouse query string, or another variable's query definition. Each
* usage is a single editable text field: `currentText` is what exists today,
* `resultingText` is the proposed rewrite (rename) or removal (delete) the user
* can review and edit before applying.
*/
export interface VariableUsage {
/** Stable key: `${sourceType}:${sourceId}:${envelopeIndex}`. */
id: string;
sourceType: 'panel' | 'variable';
/** Panel id or referencing variable's name. */
sourceId: string;
/** Human label: panel display name or `$variableName`. */
sourceLabel: string;
kind: VariableUsageKind;
/** Index into the panel's query envelopes (0 for a variable definition). */
envelopeIndex: number;
currentText: string;
resultingText: string;
}
/** The reference-bearing text + kind for one query envelope, if any. */
function envelopeReferenceText(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): { kind: VariableUsageKind; text: string } | null {
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
if (envelope.type === 'promql') {
return typeof spec?.query === 'string'
? { kind: 'promql', text: spec.query }
: null;
}
if (envelope.type === 'clickhouse_sql') {
return typeof spec?.query === 'string'
? { kind: 'clickhouse', text: spec.query }
: null;
}
return null;
}
/** The proposed text after a rename (rewrite) or delete (best-effort removal). */
function computeResultingText(
kind: VariableUsageKind,
text: string,
variableName: string,
mode: VariableImpactMode,
newName: string,
): string {
if (mode === 'rename') {
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableReferenceClause(text, variableName)
: text;
}
/**
* Finds every usage of `variableName` across the dashboard's panel queries
* (builder / PromQL / ClickHouse) and other variables' query definitions, with a
* proposed `resultingText` for the given mode. Consumed by the impact dialog that
* blocks a rename/delete until the user resolves each usage.
*/
export function findVariableUsages(
dashboard: DashboardtypesGettableDashboardV2DTO,
variableName: string,
mode: VariableImpactMode,
newName = '',
): VariableUsage[] {
if (!variableName) {
return [];
}
const usages: VariableUsage[] = [];
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;
}
toQueryEnvelopes(queries).forEach((envelope, index) => {
const ref = envelopeReferenceText(envelope);
if (!ref || !textContainsVariableReference(ref.text, variableName)) {
return;
}
usages.push({
id: `panel:${panelId}:${index}`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: ref.kind,
envelopeIndex: index,
currentText: ref.text,
resultingText: computeResultingText(
ref.kind,
ref.text,
variableName,
mode,
newName,
),
});
});
});
(spec.variables ?? []).map(dtoToFormModel).forEach((variable) => {
if (
variable.name === variableName ||
variable.type !== 'QUERY' ||
!variable.queryValue ||
!textContainsVariableReference(variable.queryValue, variableName)
) {
return;
}
usages.push({
id: `variable:${variable.name}:0`,
sourceType: 'variable',
sourceId: variable.name,
sourceLabel: `$${variable.name}`,
kind: 'variable',
envelopeIndex: 0,
currentText: variable.queryValue,
resultingText: computeResultingText(
'variable',
variable.queryValue,
variableName,
mode,
newName,
),
});
});
return usages;
}

View File

@@ -1,17 +1,15 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import CustomSelector from './selectors/CustomSelector';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import VariableValueControl from './selectors/VariableValueControl';
import { useVariableFetchState } from './useVariableFetchState';
import styles from './VariablesBar.module.scss';
import VariableTooltip from './VariableTooltip';
interface VariableSelectorProps {
variable: VariableFormModel;
@@ -34,63 +32,50 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
// Dependency links shown in the hover tooltip: variables this one's query
// references (dependsOn) and query variables that reference this one (usedBy).
const { dependsOn, usedBy } = useMemo(() => {
const references = (text: string | undefined, name: string): boolean =>
!!text && !!name && textContainsVariableReference(text, name);
return {
dependsOn:
variable.type === 'QUERY'
? variables
.filter(
(v) =>
v.name !== variable.name && references(variable.queryValue, v.name),
)
.map((v) => v.name)
: [],
usedBy: variables
.filter(
(v) =>
v.type === 'QUERY' &&
v.name !== variable.name &&
references(v.queryValue, variable.name),
)
.map((v) => v.name),
};
}, [variable, variables]);
const hasTooltip =
!!variable.description || dependsOn.length > 0 || usedBy.length > 0;
// Surface the fetch on the bar itself: a bar flush along the control's bottom
// edge while a QUERY/DYNAMIC variable is loading (or waiting on a parent), so the
// user sees options are being fetched without opening the dropdown.
const { isVariableFetching, isVariableWaiting } = useVariableFetchState(
variable.name,
);
const isFetchingOptions =
(variable.type === 'QUERY' || variable.type === 'DYNAMIC') &&
(isVariableFetching || isVariableWaiting);
const renderControl = (): JSX.Element =>
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
) : (
<VariableValueControl
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'CUSTOM':
default:
return (
<CustomSelector
variable={variable}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
}
};
return (
<div
@@ -99,29 +84,14 @@ function VariableSelector({
>
<Typography.Text className={styles.variableName}>
${variable.name}
{hasTooltip ? (
<Tooltip
title={
<VariableTooltip
description={variable.description}
dependsOn={dependsOn}
usedBy={usedBy}
/>
}
>
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size={14} />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
{isFetchingOptions ? (
<span
className={styles.loadingBar}
data-testid={`variable-loading-${variable.name}`}
/>
) : null}
</div>
);
}

View File

@@ -1,67 +0,0 @@
import cx from 'classnames';
import styles from './VariablesBar.module.scss';
interface VariableTooltipProps {
description?: string;
/** Variables this one references (its query depends on their values). */
dependsOn: string[];
/** Variables whose queries reference this one. */
usedBy: string[];
}
/** Hover-tooltip body for a variable: its description plus its dependencies. */
function VariableTooltip({
description,
dependsOn,
usedBy,
}: VariableTooltipProps): JSX.Element {
const hasDependencies = dependsOn.length > 0 || usedBy.length > 0;
return (
<div className={styles.tooltipContent}>
{description ? (
<div className={styles.tooltipDescription}>{description}</div>
) : null}
{hasDependencies ? (
<>
{description ? <div className={styles.tooltipDivider} /> : null}
{dependsOn.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.dependsColor)}>
Depends on
</div>
<div className={styles.tooltipRefs}>
{dependsOn.map((name) => (
<span
key={name}
className={cx(styles.tooltipRef, styles.dependsColor)}
>
${name}
</span>
))}
</div>
</div>
) : null}
{usedBy.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.usedByColor)}>
Used by
</div>
<div className={styles.tooltipRefs}>
{usedBy.map((name) => (
<span key={name} className={cx(styles.tooltipRef, styles.usedByColor)}>
${name}
</span>
))}
</div>
</div>
) : null}
</>
) : null}
</div>
);
}
export default VariableTooltip;

View File

@@ -73,57 +73,10 @@
}
.variableItem {
position: relative;
display: flex;
align-items: center;
}
// Loading indicator: an indeterminate bar flush along the control's bottom edge,
// full width and overlaying the border so it reads as the input's own edge rather
// than a separate element. Non-interactive so the name/description stays hoverable.
.loadingBar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
overflow: hidden;
border-radius: 0 0 2px 2px;
background: color-mix(in srgb, var(--bg-robin-500) 20%, transparent);
pointer-events: none;
}
.loadingBar::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
border-radius: 2px;
background: var(--bg-robin-500);
animation: variable-loading-slide 1.1s ease-in-out infinite;
}
@keyframes variable-loading-slide {
0% {
left: -40%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.loadingBar::after {
left: 0;
width: 100%;
animation: none;
opacity: 0.7;
}
}
.variableName {
display: flex;
min-width: 56px;
@@ -134,7 +87,7 @@
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-400);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-weight: 400;
@@ -144,63 +97,11 @@
.infoIcon {
display: inline-flex;
margin-left: 6px;
margin-left: 2px;
color: var(--l2-foreground);
vertical-align: middle;
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 240px;
}
.tooltipDescription {
font-size: 12px;
line-height: 1.5;
}
// Divider and labels use the tooltip's own text color at reduced opacity so they
// read on the tooltip surface in either theme without hard-coding a palette.
.tooltipDivider {
height: 1px;
background: currentColor;
opacity: 0.16;
}
.tooltipSection {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltipLabel {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tooltipRefs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tooltipRef {
font-size: 12px;
}
// Directional colors: parents (Depends on) in forest, children (Used by) in amber.
.dependsColor {
color: var(--bg-forest-500);
}
.usedByColor {
color: var(--bg-amber-500);
}
.variableValue {
display: flex;
min-width: 120px;

View File

@@ -1,153 +0,0 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import {
configuredDefaultValue,
reconcileWithOptions,
resolveDefaultSelection,
} from '../resolveVariableSelection';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
describe('resolveDefaultSelection', () => {
it('TEXT: uses defaultValue, then textValue, else empty string', () => {
expect(
resolveDefaultSelection(model({ type: 'TEXT', defaultValue: 'd' })),
).toStrictEqual({ value: 'd', allSelected: false });
expect(
resolveDefaultSelection(model({ type: 'TEXT', textValue: 't' })),
).toStrictEqual({ value: 't', allSelected: false });
expect(resolveDefaultSelection(model({ type: 'TEXT' }))).toStrictEqual({
value: '',
allSelected: false,
});
});
it('list: ALL when allowAll (multi + showAllOption) and no default', () => {
expect(
resolveDefaultSelection(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: ALL sentinel default → ALL', () => {
expect(
resolveDefaultSelection(
model({ type: 'CUSTOM', multiSelect: true, defaultValue: '__ALL__' }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: configured default wins over ALL default', () => {
expect(
resolveDefaultSelection(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'x',
}),
),
).toStrictEqual({ value: ['x'], allSelected: false });
});
it('list: no default and no allowAll → empty placeholder (filled after fetch)', () => {
expect(resolveDefaultSelection(model({ type: 'QUERY' }))).toStrictEqual({
value: '',
allSelected: false,
});
expect(
resolveDefaultSelection(model({ type: 'QUERY', multiSelect: true })),
).toStrictEqual({ value: [], allSelected: false });
});
});
describe('reconcileWithOptions', () => {
it('leaves a valid single selection untouched (local-first)', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: 'b', allSelected: false },
['a', 'b'],
),
).toBeNull();
});
it('materializes query ALL to the full option array', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('does not materialize dynamic ALL (sends __all__)', () => {
expect(
reconcileWithOptions(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toBeNull();
});
it('keeps the still-valid subset when options re-scope', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true }),
{ value: ['a', 'b', 'c'], allSelected: false },
['a', 'b', 'd'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('falls back to the configured default (else first) when invalid', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', defaultValue: 'b' }),
{ value: '', allSelected: false },
['a', 'b', 'c'],
),
).toStrictEqual({ value: 'b', allSelected: false });
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
['a', 'b'],
),
).toStrictEqual({ value: 'a', allSelected: false });
});
it('does nothing while options are empty', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
[],
),
).toBeNull();
});
});
describe('configuredDefaultValue', () => {
it('TEXT: textValue fallback; list: defaultValue only (no ALL synthesis)', () => {
expect(configuredDefaultValue(model({ type: 'TEXT', textValue: 't' }))).toBe(
't',
);
expect(
configuredDefaultValue(model({ type: 'QUERY', defaultValue: 'x' })),
).toBe('x');
// ALL-by-default list variable is not expanded here (options unknown).
expect(
configuredDefaultValue(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,31 @@
import { withVariablesSearch } from '../variablesUrlState';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
}));
describe('withVariablesSearch', () => {
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
'{"env":"prod"}',
)}`;
it('returns the base unchanged when the current search has no variables', () => {
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
'?panelKind=signoz/TablePanel',
);
});
it('carries only the variables param onto an empty base', () => {
const result = withVariablesSearch('', current);
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
});
it('appends the variables param to existing base params', () => {
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
const params = new URLSearchParams(result);
expect(params.get('panelKind')).toBe('signoz/TablePanel');
expect(params.get('variables')).toBe('{"env":"prod"}');
});
});

View File

@@ -1,183 +0,0 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
import { ALL_SELECTED } from './variablesUrlState';
/**
* Single source of truth for "what value does this variable hold?", shared by the
* three surfaces that used to each own a divergent copy of the rule:
* - {@link resolveDefaultSelection} — the seed-time default (no options yet).
* - {@link reconcileWithOptions} — the post-fetch reconcile (options known).
* - {@link configuredDefaultValue} — the payload fallback when nothing is picked.
*
* Keeping them here means the variable bar, the fetch gate and the panel-query
* payload can never disagree about a variable's default (the previous split
* produced "bar shows ALL while the query omits the variable").
*/
/** An "every option selected" (ALL) selection. */
const ALL_SELECTION: VariableSelection = { value: null, allSelected: true };
/** The `defaultValue` reduced to a single string, or undefined when unset. */
function firstConfiguredDefault(model: VariableFormModel): string | undefined {
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? String(def[0]) : undefined;
}
if (typeof def === 'string' && def !== '') {
return def;
}
return undefined;
}
/** Whether the configured default marks the ALL sentinel. */
function isAllDefault(def: VariableFormModel['defaultValue']): boolean {
return (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
);
}
function isValidSingle(
value: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(value) &&
value !== '' &&
value !== null &&
value !== undefined &&
options.includes(String(value))
);
}
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/**
* For an ALL selection, the value to materialize (or null when unchanged).
* Dynamic ALL travels as the `__all__` wire sentinel and renders ALL from the
* flag, so it needs no materialized value. Query/custom ALL must carry the full
* option array (the payload builder cannot expand it) — keep it in sync.
*/
function materializeAll(
model: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
if (!model.multiSelect || model.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
/**
* The seed-time default for a variable, before any options are fetched.
* - TEXT: the configured default (`defaultValue` → `textValue`), else empty.
* - CUSTOM/QUERY/DYNAMIC: the configured default; else ALL when allowAll is on;
* else a placeholder that {@link reconcileWithOptions} fills with the first
* option once the options resolve.
*/
export function resolveDefaultSelection(
model: VariableFormModel,
): VariableSelection {
if (model.type === 'TEXT') {
return {
value: firstConfiguredDefault(model) ?? model.textValue ?? '',
allSelected: false,
};
}
const def = model.defaultValue;
if (isAllDefault(def)) {
return ALL_SELECTION;
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return ALL_SELECTION;
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
/**
* Reconciles a variable's current selection against its freshly-fetched options.
* Returns the next selection, or null when nothing should change (a valid pick is
* left untouched — local-first). Behaviour, in order:
* - materialize ALL to the full option set (query/custom);
* - keep a still-valid multi-select subset, dropping only invalid entries;
* - otherwise auto-pick the default (or first option) so dependent variables and
* panels always resolve against a usable value.
*/
export function reconcileWithOptions(
model: VariableFormModel,
current: VariableSelection,
options: string[],
): VariableSelection | null {
if (options.length === 0) {
return null;
}
if (current.allSelected) {
return materializeAll(model, options, current.value);
}
if (
model.multiSelect &&
Array.isArray(current.value) &&
current.value.length > 0
) {
const valid = current.value.map(String).filter((c) => options.includes(c));
if (valid.length === current.value.length) {
return null;
}
return valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(model, options);
}
if (!model.multiSelect && isValidSingle(current.value, options)) {
return null;
}
return fillDefault(model, options);
}
/**
* The value to send for a variable when the user has made no selection yet
* (the payload fallback). Mirrors the configured default only — an ALL-by-default
* list variable resolves to `undefined` here (its concrete values are carried by
* the materialized selection once options are known), so it is omitted until then
* rather than sent wrong.
*/
export function configuredDefaultValue(
model: VariableFormModel,
): SelectedVariableValue | undefined {
if (model.type === 'TEXT') {
return firstConfiguredDefault(model) ?? model.textValue ?? undefined;
}
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}

View File

@@ -1,4 +1,3 @@
import type { VariableType } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
@@ -20,32 +19,6 @@ export function isResolved(selection?: VariableSelection): boolean {
return value !== '' && value !== null && value !== undefined;
}
/**
* Whether a selection carries a value usable when scheduling a dependent
* variable/panel fetch. Unlike {@link isResolved}, a QUERY/CUSTOM ALL counts only
* once materialized into the concrete array (an unmaterialized ALL isn't usable),
* while a DYNAMIC ALL is usable immediately via the `__all__` sentinel.
*/
export function hasUsableValue(
selection: VariableSelection | undefined,
type: VariableType | undefined,
): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
if (type === 'DYNAMIC') {
return true;
}
return Array.isArray(selection.value) && selection.value.length > 0;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface CustomSelectorProps {
variable: VariableFormModel;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Custom-variable options come from the comma-separated `customValue` (no fetch),
* but still auto-select a default/first option so the variable is never left blank.
*/
function CustomSelector({
variable,
selection,
onChange,
onAutoSelect,
}: CustomSelectorProps): JSX.Element {
const options = useMemo(
() =>
sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String),
[variable.customValue, variable.sort],
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default CustomSelector;

View File

@@ -0,0 +1,140 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
* the runtime fetch engine: dynamics fetch together once the query variables have
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
existingQuery,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
* per-variable `cycleId` keys the request — so a parent's value change refetches
* only the dependent variables, in dependency order. The current selections feed
* the request payload but are deliberately NOT in the key (V1 parity).
*/
function QuerySelector({
variable,
selections,
selection,
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -1,59 +0,0 @@
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
import { useVariableOptions } from './useVariableOptions';
interface VariableValueControlProps {
variable: VariableFormModel;
/** All variables (Dynamic scopes its options by sibling selections). */
variables: VariableFormModel[];
/** All current selections (fed to the Query request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* The single value picker for QUERY / CUSTOM / DYNAMIC variables. Options + fetch
* state come from {@link useVariableOptions}; this component only reconciles the
* selection against the options and renders — the view is decoupled from how the
* options are sourced (Container/Presentational).
*/
function VariableValueControl({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: VariableValueControlProps): JSX.Element {
const { options, loading, errorMessage, onRetry } = useVariableOptions(
variable,
variables,
selections,
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default VariableValueControl;

View File

@@ -1,213 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type { VariableSelectionMap } from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useVariableFetchState } from '../useVariableFetchState';
export interface VariableOptions {
options: string[];
loading: boolean;
errorMessage: string | null;
onRetry?: () => void;
}
/**
* The option list for a list variable (QUERY / CUSTOM / DYNAMIC), plus its loading
* and error state — the single place the three list types get their options.
* QUERY/DYNAMIC fetch via react-query (WHEN owned by the fetch engine: `enabled`
* gated on the variable's fetch state, keyed by `cycleId`, never by the current
* selections or time — those feed the fetchers (which read the current time at
* call), so the debounced fetch cycle drives refetches). CUSTOM is parsed
* synchronously from its comma list. TEXT never reaches here (it has no options).
*/
export function useVariableOptions(
variable: VariableFormModel,
variables: VariableFormModel[],
selections: VariableSelectionMap,
): VariableOptions {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Bound cache churn: 0 under auto-refresh so entries don't pile up (V1 parity).
const cacheTime = isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED;
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const setVariableResolvedEmpty = useDashboardStore(
(s) => s.setVariableResolvedEmpty,
);
// Fetch while this variable is actively fetching, or once settled after a first
// fetch (so a `cycleId` bump re-runs it). Combined with a per-type guard below.
const canFetch =
isVariableFetching || (isVariableSettled && hasVariableFetchedOnce);
// QUERY — options from the test-run endpoint; selections feed the payload, not the key.
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const queryResult = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: variable.type === 'QUERY' && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
// DYNAMIC — telemetry field values scoped by sibling dynamics via `existingQuery`
// (fed to the fetcher only, not the key — see DynamicSelector history).
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const dynamicResult = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const queryOptions = useMemo(() => {
const data = queryResult.data;
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [queryResult.data, variable.sort]);
const dynamicOptions = useMemo(() => {
const data = dynamicResult.data?.data;
const values = data?.normalizedValues ?? data?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [dynamicResult.data, variable.sort]);
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);
// Flag a variable that settled with zero options so dependent panels fall through
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
const effectiveOptions =
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
useEffect(() => {
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
return;
}
setVariableResolvedEmpty(
variable.name,
hasVariableFetchedOnce &&
!isVariableFetching &&
effectiveOptions.length === 0,
);
}, [
variable.type,
variable.name,
hasVariableFetchedOnce,
isVariableFetching,
effectiveOptions.length,
setVariableResolvedEmpty,
]);
if (variable.type === 'CUSTOM') {
return { options: customOptions, loading: false, errorMessage: null };
}
if (variable.type === 'DYNAMIC') {
return {
options: dynamicOptions,
loading: dynamicResult.isFetching || isVariableWaiting,
errorMessage: dynamicResult.error
? (dynamicResult.error as Error).message || null
: null,
onRetry: (): void => {
void dynamicResult.refetch();
},
};
}
return {
options: queryOptions,
loading: queryResult.isFetching || isVariableWaiting,
errorMessage: queryResult.error
? (queryResult.error as Error).message || null
: null,
onRetry: (): void => {
void queryResult.refetch();
},
};
}

View File

@@ -1,14 +1,61 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { reconcileWithOptions } from './resolveVariableSelection';
import type { VariableSelection } from './selectionTypes';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
/** The variable's default (or first option) as a fresh selection. */
function fillDefault(
variable: VariableFormModel,
options: string[],
): VariableSelection {
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/** For an all-selected variable, the value to materialize (or null if unchanged). */
function reconcileAllSelected(
variable: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
// Dynamic ALL travels as the `__all__` wire sentinel and shows ALL from the
// flag, so it needs no materialized value. Query/custom ALL must carry the full
// option array (the payload builder can't expand it) — keep it in sync.
if (!variable.multiSelect || variable.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
function isValidSingle(
current: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(current) &&
current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current))
);
}
/**
* Reconciles a variable's selection with its freshly-fetched options and fires
* `onAutoSelect` only when the value must change. The reconcile rule lives in
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
* and the panel query can never disagree about a variable's default.
* Reconciles a variable's selection with its freshly-fetched options: materialize
* ALL to the full set, keep a still-valid multi-select subset, else auto-pick the
* default (or first option) so dependent children always have a usable value.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -17,10 +64,36 @@ export function useAutoSelect(
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
if (options.length === 0) {
return;
}
const current = selection.value;
if (selection.allSelected) {
const next = reconcileAllSelected(variable, options, current);
if (next) {
onAutoSelect(next);
}
return;
}
if (variable.multiSelect && Array.isArray(current) && current.length > 0) {
const valid = current.map(String).filter((c) => options.includes(c));
if (valid.length === current.length) {
return;
}
onAutoSelect(
valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(variable, options),
);
return;
}
if (!variable.multiSelect && isValidSingle(current, options)) {
return;
}
onAutoSelect(fillDefault(variable, options));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -6,7 +6,6 @@ import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters'
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolveDefaultSelection } from './resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -18,6 +17,26 @@ import {
} from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = model.defaultValue;
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -69,20 +88,12 @@ export function useSeedVariableSelection(
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (selection[variable.name]) {
seeded[variable.name] = selection[variable.name];
} else {
seeded[variable.name] = resolveDefaultSelection(variable);
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
@@ -105,10 +116,8 @@ export function useSeedVariableSelection(
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
}, [dashboardId, variables]);
// Always init the context (even with no variables) so panels can tell "ready, none"
// from "not ready yet"; also clears it when the last variable is removed.
useEffect(() => {
if (!dashboardId) {
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables

View File

@@ -11,14 +11,9 @@ import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import { useSeedVariableSelection } from './useSeedVariableSelection';
import { doAllQueryVariablesHaveValues } from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
/**
* Debounce for the fetch cycle, so the on-load time-range settle (default → saved)
* and rapid time-picker changes collapse into one cycle instead of double-fetching.
*/
const FETCH_CYCLE_DEBOUNCE_MS = 250;
interface UseVariableSelection {
variables: VariableFormModel[];
selection: VariableSelectionMap;
@@ -53,10 +48,9 @@ export function useVariableSelection(
(s) => s.enqueueDescendantsBatch,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Latest selection, read by the fetch-cycle effect without subscribing to it
// (so a value change doesn't re-trigger a full fetch cycle).
@@ -68,39 +62,20 @@ export function useVariableSelection(
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Start a full fetch cycle on load / dependency-order / time change, debounced so
// the initial time-window settle (and rapid time changes) collapse into ONE cycle
// instead of double-fetching every variable. Variables stay disabled until the
// cycle runs, so the transient window is never fetched. A value change instead
// goes through `enqueueDescendants` — immediate, not this effect.
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
// Key on the time *selection*, not raw min/max: a relative range recomputes those
// as `now` drifts, which shouldn't refetch. The fetchers still read current time.
const timeKey =
selectedTime === 'custom' ? `custom:${minTime}-${maxTime}` : selectedTime;
// A re-mount re-runs this effect with the same key, which enqueueFetchAll skips.
const fetchCycleKey = `${dashboardId}|${orderKey}|${timeKey}`;
const fetchCycleTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return undefined;
return;
}
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
fetchCycleTimer.current = setTimeout(
() => enqueueFetchAll(fetchCycleKey),
FETCH_CYCLE_DEBOUNCE_MS,
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
);
return (): void => {
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, fetchCycleKey]);
}, [dashboardId, orderKey, minTime, maxTime]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {

View File

@@ -4,6 +4,8 @@ import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
import { isResolved } from './selectionUtils';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
@@ -240,3 +242,17 @@ export function deriveFetchContext(
dynamicVariableOrder,
};
}
/**
* Whether every QUERY variable already has a usable selection — decides at load
* time whether dynamic variables may fetch immediately or must wait for the
* query variables to settle first (V1 parity).
*/
export function doAllQueryVariablesHaveValues(
variables: VariableFormModel[],
selection: VariableSelectionMap,
): boolean {
return variables
.filter((v) => v.type === 'QUERY')
.every((v) => isResolved(selection[v.name]));
}

View File

@@ -1,3 +1,4 @@
import { QueryParams } from 'constants/query';
import { parseAsJson } from 'nuqs';
import type { SelectedVariableValue } from './selectionTypes';
@@ -13,3 +14,21 @@ export const variablesUrlParser = parseAsJson<
? (v as Record<string, SelectedVariableValue>)
: null,
);
/**
* Extends a search string with the current `?variables=` param (unchanged when
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
* it survives a refresh (V1 parity).
*/
export function withVariablesSearch(
base: string,
currentSearch: string,
): string {
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
if (!value) {
return base;
}
const params = new URLSearchParams(base);
params.set(QueryParams.variables, value);
return `?${params.toString()}`;
}

View File

@@ -1,11 +1,12 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import { generatePath, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -25,6 +26,7 @@ interface UseCreatePanelResult {
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -48,10 +50,11 @@ export function useCreatePanel(): UseCreatePanelResult {
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
safeNavigate(
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
);
},
[safeNavigate, dashboardId, layoutIndex],
[safeNavigate, dashboardId, layoutIndex, search],
);
return {

View File

@@ -1,51 +1,23 @@
import { hasUsableValue } from '../VariablesBar/selectionUtils';
import { VariableFetchState } from '../store/slices/variableFetchSlice';
import { isResolved } from '../VariablesBar/selectionUtils';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Whether a panel should stay loading because a QUERY/DYNAMIC variable it references
* isn't ready to substitute. A concrete pick (not ALL) and a DYNAMIC ALL are ready
* immediately; an unselected value or a QUERY/CUSTOM ALL waits while it's still
* resolving, then until it settles with a value — so a panel on a chain holds until
* the last variable it depends on resolves. A fetch error or a settled-empty variable
* releases it (no value is coming — render rather than hang).
* True while a panel should stay in its loading state because a variable it
* references is still loading/waiting and has no usable value yet — i.e. the
* first load. Once the variable has a value, a later change no longer blocks the
* panel (it refetches over stale data instead). V1 parity with
* `useIsPanelWaitingOnVariable`.
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variableTypes = useDashboardStore(
(s) => s.variableFetchContext?.variableTypes,
);
const fetchStates = useDashboardStore((s) => s.variableFetchStates);
const resolvedEmpty = useDashboardStore((s) => s.variableResolvedEmpty);
const states = useDashboardStore((s) => s.variableFetchStates);
const selection = useDashboardStore(selectVariableValues(dashboardId));
return names.some((name) => {
const type = variableTypes?.[name];
if (type !== 'QUERY' && type !== 'DYNAMIC') {
return false;
}
const value = selection[name];
// A concrete pick is authoritative; a DYNAMIC ALL is the stable `__all__`
// sentinel — both ready without waiting.
if (value && !value.allSelected && hasUsableValue(value, type)) {
return false;
}
if (type === 'DYNAMIC' && value?.allSelected) {
return false;
}
// Unselected, or a QUERY/CUSTOM ALL whose array the fetch produces: wait while
// resolving, then until it settles with a usable value.
const state = fetchStates[name];
if (
state === VariableFetchState.Waiting ||
state === VariableFetchState.Loading
) {
return true;
}
if (hasUsableValue(value, type)) {
return false;
}
return state !== VariableFetchState.Error && !resolvedEmpty[name];
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
});
}

View File

@@ -1,16 +1,17 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import { generatePath, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* caller can open the editor with just the panel id. The `?variables=` selection is carried
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
@@ -20,15 +21,19 @@ export function useOpenPanelEditor(): (
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
})}${withVariablesSearch('', search)}`,
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId],
[safeNavigate, dashboardId, search],
);
}

View File

@@ -19,10 +19,7 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import {
getReferencedVariables,
queryReferencesAnyVariable,
} from '../queryV5/getReferencedVariables';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
@@ -60,9 +57,9 @@ export interface PanelQueryTimeOverride {
export interface UsePanelQueryResult {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
/** First fetch only (no cached data yet), OR waiting on an unresolved referenced variable — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
/** First fetch only (no cached data yet) — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
isLoading: boolean;
/** Any request in flight (including a background refetch over stale data), OR waiting on an unresolved referenced variable — drives the loader / "refreshing" affordance, never a blank panel. */
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
isFetching: boolean;
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
isPreviousData: boolean;
@@ -134,13 +131,6 @@ export function usePanelQuery({
return getReferencedVariables(queries, allNames);
}, [queries, fetchContext]);
// Detected without the fetch context, so the gate below can hold even before it
// initializes.
const hasVariableReference = useMemo(
() => queryReferencesAnyVariable(queries),
[queries],
);
const scopedVariables = useMemo(() => {
const scoped: typeof variables = {};
referencedVariableNames.forEach((name) => {
@@ -151,11 +141,11 @@ export function usePanelQuery({
return scoped;
}, [variables, referencedVariableNames]);
// Hold until referenced variables resolve; also hold before the context is ready
// (we can't yet know which variables to substitute, so firing would drop `$var`s).
const isWaitingOnVariable =
useIsPanelWaitingOnVariable(referencedVariableNames) ||
(hasVariableReference && !fetchContext);
// First-load gate: hold the panel in its loading state until every referenced
// variable has resolved a value.
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
referencedVariableNames,
);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -319,10 +309,8 @@ export function usePanelQuery({
return {
data,
// A disabled (waiting-on-variable) query reports neither loading nor fetching, so
// fold the wait in — else the panel body falls through to "No data" mid-load.
isLoading: isWaitingOnVariable || response.isLoading,
isFetching: isWaitingOnVariable || response.isFetching,
isLoading: response.isLoading,
isFetching: response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,

View File

@@ -33,11 +33,6 @@ function DashboardContainer({
document.title = name;
}, [name]);
// Store is app-level and outlives the page: clear transient variable fetch state on
// unmount so the next visit doesn't inherit stale states / climbing cycle ids.
const resetVariableFetch = useDashboardStore((s) => s.resetVariableFetch);
useEffect(() => resetVariableFetch, [resetVariableFetch]);
const fullScreenHandle = useFullScreenHandle();
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);

View File

@@ -1,69 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import {
getReferencedVariables,
queryReferencesAnyVariable,
} from '../getReferencedVariables';
// Test fixtures are cast at the outer boundary; the perses-generated query
// plugin unions are too verbose to construct field-typed inline.
function clickhouseQuery(query: string): DashboardtypesQueryDTO[] {
return [
{
kind: 'ScalarQuery',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [{ type: 'clickhouse_sql', spec: { name: 'A', query } }],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
}
describe('getReferencedVariables', () => {
it('returns only the variables the query references', () => {
const queries = clickhouseQuery(
'SELECT count() FROM t WHERE service = $service.name',
);
expect(
getReferencedVariables(queries, [
'service.name',
'deployment.environment',
'dyn_service',
]),
).toStrictEqual(['service.name']);
});
it('returns empty when no known name matches', () => {
const queries = clickhouseQuery('SELECT 1');
expect(getReferencedVariables(queries, ['service.name'])).toStrictEqual([]);
});
});
describe('queryReferencesAnyVariable', () => {
it('is true when the query references a variable, even with no known names', () => {
const queries = clickhouseQuery(
'SELECT count() FROM t WHERE service = $service.name',
);
expect(queryReferencesAnyVariable(queries)).toBe(true);
});
it('is false for a query with no variable reference', () => {
expect(queryReferencesAnyVariable(clickhouseQuery('SELECT 1'))).toBe(false);
});
it('does not treat $__ macros as variable references', () => {
expect(
queryReferencesAnyVariable(
clickhouseQuery('SELECT toStartOfInterval(ts, INTERVAL $__interval)'),
),
).toBe(false);
});
it('is false for an empty query list', () => {
expect(queryReferencesAnyVariable([])).toBe(false);
});
});

View File

@@ -8,8 +8,8 @@ import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import { configuredDefaultValue } from '../VariablesBar/resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from '../VariablesBar/selectionTypes';
@@ -33,6 +33,21 @@ const VARIABLE_TYPE_TO_DTO: Record<
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
};
/** The variable's configured default, used when nothing is selected yet. */
function configuredDefault(
definition: VariableFormModel,
): SelectedVariableValue | undefined {
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
// `defaultValue` is `string | string[]` on the wire — use it directly.
const def = definition.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}
/**
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
* user's selection, else the configured default. Returns `undefined` when there
@@ -59,7 +74,7 @@ function resolveValue(
return selected as Querybuildertypesv5VariableItemDTOValue;
}
const fallback = configuredDefaultValue(definition);
const fallback = configuredDefault(definition);
return fallback == null
? undefined
: (fallback as Querybuildertypesv5VariableItemDTOValue);

View File

@@ -1,8 +1,5 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import {
containsAnyVariableReference,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
@@ -48,18 +45,3 @@ export function getReferencedVariables(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Whether a panel's queries reference *any* variable, independent of the known
* variable set. Used to hold the panel until the variable fetch context is ready:
* before then the variable names aren't known, so firing would substitute nothing
* (dropping every `$var`) and the query would fail.
*/
export function queryReferencesAnyVariable(
queries: DashboardtypesQueryDTO[],
): boolean {
if (queries.length === 0) {
return false;
}
return extractQueryTexts(queries).some(containsAnyVariableReference);
}

View File

@@ -12,25 +12,14 @@ function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
const DASH = 'test-dash';
function store(): ReturnType<typeof useDashboardStore.getState> {
return useDashboardStore.getState();
}
function states(): Record<string, string> {
return store().variableFetchStates;
}
/** Commit a value for a variable (what a parent must have before a child fetches). */
function resolve(name: string): void {
store().setVariableValue(DASH, name, {
value: `${name}-v`,
allSelected: false,
});
}
function reset(names: string[], context: VariableFetchContext): void {
useDashboardStore.setState({
dashboardId: DASH,
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
@@ -58,37 +47,28 @@ describe('variableFetchSlice', () => {
});
});
it('loads query roots + dynamics immediately and waits query dependents', () => {
store().enqueueFetchAll();
// Dynamics fetch immediately (not gated on the query chain); the query
// dependent q2 waits for its parent q1.
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
expect(states()).toMatchObject({
q1: 'loading',
q2: 'waiting',
d1: 'loading',
d2: 'loading',
d1: 'waiting',
d2: 'waiting',
});
});
it('a completed parent alone does not unblock the child; its committed value does', () => {
store().enqueueFetchAll();
store().onVariableFetchComplete('q1');
// q1 finished fetching but has not auto-selected a value yet, so q2 holds
// rather than fetching with q1 unresolved. Dynamics load regardless.
expect(states()).toMatchObject({ q1: 'idle', q2: 'waiting', d1: 'loading' });
// q1's value commits → the value cascade unblocks q2.
resolve('q1');
store().enqueueDescendants('q1');
expect(states().q2).not.toBe('waiting');
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
store().enqueueFetchAll(true);
expect(states().d1).toBe('loading');
});
it('unblocks a query child immediately when its parent already has a value', () => {
// Persisted/pre-seeded selection: q1 has a value before it even fetches, so
// completing its fetch unblocks q2 straight away (a single fetch, no cascade).
resolve('q1');
store().enqueueFetchAll();
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
expect(states().q2).not.toBe('waiting');
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
});
it('ignores a settle for a variable that is not actively fetching', () => {
@@ -99,14 +79,8 @@ describe('variableFetchSlice', () => {
});
it('changing a query variable revalidates query descendants but NOT dynamics', () => {
// Drive the chain to a fully settled state: q1 fetched + valued, q2 fetched.
store().enqueueFetchAll();
store().onVariableFetchComplete('q1');
resolve('q1');
store().enqueueDescendants('q1');
store().onVariableFetchComplete('q2');
resolve('q2');
['d1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
store().enqueueDescendants('q1');
@@ -117,7 +91,7 @@ describe('variableFetchSlice', () => {
});
it('changing a dynamic refreshes the OTHER dynamics, never itself or query vars', () => {
store().enqueueFetchAll();
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
@@ -128,29 +102,13 @@ describe('variableFetchSlice', () => {
});
it('a failed parent idles its query descendants', () => {
store().enqueueFetchAll();
store().enqueueFetchAll(false);
store().onVariableFetchFailure('q1');
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
});
describe('variableFetchSlice — query depends on a dynamic', () => {
// qd (query) references $dyn (a dynamic variable).
const dyn = model({ name: 'dyn', type: 'DYNAMIC', dynamicAttribute: 'pod' });
const qd = model({ name: 'qd', type: 'QUERY', queryValue: 'SELECT $dyn' });
const context = deriveFetchContext([dyn, qd]);
beforeEach(() => reset(['dyn', 'qd'], context));
it('does not wait for a dynamic parent — both load immediately', () => {
store().enqueueFetchAll();
// A dynamic's selected value is already in the selection, so the dependent
// query never waits on the dynamic's option fetch; both start together.
expect(states()).toMatchObject({ dyn: 'loading', qd: 'loading' });
});
});
describe('variableFetchSlice — diamond dependencies', () => {
// qA, qB (roots) → qC (references both $qA and $qB).
const qA = model({ name: 'qA', type: 'QUERY', queryValue: 'SELECT 1' });
@@ -160,19 +118,15 @@ describe('variableFetchSlice — diamond dependencies', () => {
beforeEach(() => reset(['qA', 'qB', 'qC'], context));
it('unblocks the child only once BOTH parents have committed values', () => {
store().enqueueFetchAll();
it('unblocks the child only once BOTH parents are settled', () => {
store().enqueueFetchAll(false);
expect(states().qC).toBe('waiting');
store().onVariableFetchComplete('qA');
resolve('qA');
store().enqueueDescendants('qA');
expect(states().qC).toBe('waiting'); // qB has no value yet
expect(states().qC).toBe('waiting'); // qB still loading
store().onVariableFetchComplete('qB');
resolve('qB');
store().enqueueDescendants('qB');
expect(states().qC).not.toBe('waiting'); // both valued → fetches
expect(states().qC).not.toBe('waiting'); // both settled → fetches
});
});
@@ -185,7 +139,7 @@ describe('variableFetchSlice — dependency cycle', () => {
beforeEach(() => reset(['qX', 'qY'], context));
it('enqueues cyclic query variables as best-effort roots (not silently idle)', () => {
store().enqueueFetchAll();
store().enqueueFetchAll(false);
expect(states().qX).not.toBe('idle');
expect(states().qY).not.toBe('idle');
});

View File

@@ -1,42 +1,23 @@
import type { StateCreator } from 'zustand';
import { hasUsableValue } from '../../VariablesBar/selectionUtils';
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
import type { DashboardStore } from '../useDashboardStore';
import { selectVariableValues } from './variableSelectionSlice';
import {
areAllQueryVariablesSettled,
type FetchMaps,
isSettled,
isVariableInActiveFetchState,
resolveFetchState,
unlockWaitingDynamicVariables,
VariableFetchState,
} from './variableFetchSlice.utils';
/**
* Whether every QUERY parent of `name` holds a committed value. Gating a child on its
* parents' *values* (not their settled fetch state) makes it fetch once, after the
* values commit — not prematurely on fetch-complete and again on value-commit.
*/
function queryParentsHaveValues(
name: string,
context: VariableFetchContext,
selection: VariableSelectionMap,
): boolean {
const parents = context.dependencyData.parentGraph[name] || [];
return parents.every(
(p) =>
context.variableTypes[p] !== 'QUERY' ||
hasUsableValue(selection[p], context.variableTypes[p]),
);
}
export { VariableFetchState } from './variableFetchSlice.utils';
/**
* Runtime fetch orchestration for dashboard variables — native port of V1's
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
* variables in dependency order, dynamics immediately (they are scoped only by
* sibling dynamic selections, never by query variables, so nothing gates them),
* variables in dependency order, dynamics together once query values exist,
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
@@ -45,35 +26,13 @@ export interface VariableFetchSlice {
variableFetchStates: Record<string, VariableFetchState>;
variableLastUpdated: Record<string, number>;
variableCycleIds: Record<string, number>;
/**
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
* will never get a value). Lets a dependent panel fall through to "no data"
* instead of waiting forever on a value that isn't coming.
*/
variableResolvedEmpty: Record<string, boolean>;
/** Static dependency context, set by `initVariableFetch` (null before init). */
variableFetchContext: VariableFetchContext | null;
/**
* Signature (dashboard + time + variable order) of the last full fetch cycle.
* A repeat `enqueueFetchAll` with the same signature is skipped, so a component
* re-mount can't redo the cycle and double every variable's fetch.
*/
lastFetchAllKey: string | null;
/** Seed state entries for the current variable set and store the context. */
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
/**
* Clear all transient fetch state on dashboard-page unmount, so a later visit
* starts clean instead of inheriting stale state from this app-level store.
*/
resetVariableFetch: () => void;
/** Record whether a variable settled with no options (drives the panel gate). */
setVariableResolvedEmpty: (name: string, isEmpty: boolean) => void;
/**
* Start a full fetch cycle for every fetchable variable (load / time change).
* A repeat call with the same signature `key` is a no-op (idempotent re-mount).
*/
enqueueFetchAll: (key?: string) => void;
/** Start a full fetch cycle for every fetchable variable (load / time change). */
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
onVariableFetchComplete: (name: string) => void;
/** Mark a variable's fetch as failed; idle its query descendants. */
@@ -106,32 +65,10 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
resetVariableFetch: (): void => {
set({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
},
setVariableResolvedEmpty: (name, isEmpty): void => {
const current = get().variableResolvedEmpty;
if ((current[name] ?? false) === isEmpty) {
return;
}
set({ variableResolvedEmpty: { ...current, [name]: isEmpty } });
},
initVariableFetch: (names, context): void => {
const maps = cloneMaps(get());
const resolvedEmpty = { ...get().variableResolvedEmpty };
// Initialize new variables to idle, preserving existing states.
names.forEach((name) => {
if (!maps.states[name]) {
@@ -145,23 +82,17 @@ export const createVariableFetchSlice: StateCreator<
delete maps.states[name];
delete maps.lastUpdated[name];
delete maps.cycleIds[name];
delete resolvedEmpty[name];
}
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableResolvedEmpty: resolvedEmpty,
variableFetchContext: context,
});
},
enqueueFetchAll: (key): void => {
// Skip a redundant re-run (re-mount with identical inputs) — else it doubles.
if (key && key === get().lastFetchAllKey) {
return;
}
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
@@ -174,11 +105,7 @@ export const createVariableFetchSlice: StateCreator<
} = variableFetchContext;
const maps = cloneMaps(get());
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
// gate: its option fetch feeds only its own dropdown, while its selected value
// (ALL → `__all__`, or a concrete pick) is already in the selection, so a
// dependent query substitutes it immediately and refetches via the cascade if
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
// Query variables: roots start immediately, dependents wait for parents.
queryVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
@@ -189,8 +116,8 @@ export const createVariableFetchSlice: StateCreator<
});
// Query variables dropped from the dependency order (part of a cycle) would
// otherwise never fetch — start them as best-effort roots so they surface
// data/an error instead of sitting empty.
// otherwise never fetch and would stall waiting dynamics — start them as
// best-effort roots so they surface data/an error instead of sitting empty.
const orderedQuery = new Set(queryVariableOrder);
Object.keys(variableTypes).forEach((name) => {
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
@@ -199,21 +126,19 @@ export const createVariableFetchSlice: StateCreator<
}
});
// Dynamic variables fetch immediately, in parallel with the query variables:
// their options are scoped only by sibling dynamic selections (never by query
// variables), so there is nothing to wait for. Starting early lets them
// populate fast even when query variables are slow; a sibling selection change
// later refetches them via `enqueueDescendantsBatch`.
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
dynamicVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
maps.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(maps, name)
: VariableFetchState.Waiting;
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
lastFetchAllKey: key ?? get().lastFetchAllKey,
});
},
@@ -229,11 +154,10 @@ export const createVariableFetchSlice: StateCreator<
maps.lastUpdated[name] = Date.now();
if (variableFetchContext) {
const { dependencyData, variableTypes } = variableFetchContext;
const selection = selectVariableValues(get().dashboardId)(get());
// Release a waiting child only if its parents are already valued (e.g. a
// persisted selection). For a just-fetched parent whose value hasn't committed
// yet, the value cascade (enqueueDescendantsBatch) unblocks it instead.
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Unblock a waiting query child only once ALL its parents are settled —
// otherwise it would fetch against a not-yet-resolved parent.
(dependencyData.graph[name] || []).forEach((child) => {
if (
variableTypes[child] !== 'QUERY' ||
@@ -241,10 +165,18 @@ export const createVariableFetchSlice: StateCreator<
) {
return;
}
if (queryParentsHaveValues(child, variableFetchContext, selection)) {
const parents = dependencyData.parentGraph[child] || [];
if (parents.every((p) => isSettled(maps.states[p]))) {
maps.states[child] = resolveFetchState(maps, child);
}
});
// Once all query variables settle, unlock any waiting dynamics.
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
@@ -263,14 +195,20 @@ export const createVariableFetchSlice: StateCreator<
maps.states[name] = VariableFetchState.Error;
if (variableFetchContext) {
const { dependencyData, variableTypes } = variableFetchContext;
// Idle query descendants only when a QUERY parent fails (they need its
// value); a DYNAMIC failure doesn't block them (they used its selection).
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Query descendants can't proceed without this parent — idle them.
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
if (variableTypes[name] === 'QUERY' && variableTypes[desc] === 'QUERY') {
if (variableTypes[desc] === 'QUERY') {
maps.states[desc] = VariableFetchState.Idle;
}
});
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
@@ -293,11 +231,9 @@ export const createVariableFetchSlice: StateCreator<
variableFetchContext;
const maps = cloneMaps(get());
const changed = new Set(names);
// Callers commit values before this runs, so the gate sees the new parent values.
const selection = selectVariableValues(get().dashboardId)(get());
// Query descendants of the changed vars (not the changed ones): fetch once all
// their query parents have a value, else hold until the rest land.
// Union of the changed variables' query descendants (never the changed ones
// themselves), refreshed once each: refetch when all parents are settled.
const queryDescendants = new Set<string>();
names.forEach((name) => {
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
@@ -308,24 +244,27 @@ export const createVariableFetchSlice: StateCreator<
});
queryDescendants.forEach((desc) => {
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
maps.states[desc] = queryParentsHaveValues(
desc,
variableFetchContext,
selection,
)
const parents = dependencyData.parentGraph[desc] || [];
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
maps.states[desc] = allParentsSettled
? resolveFetchState(maps, desc)
: VariableFetchState.Waiting;
});
// A dynamic's options depend only on its sibling DYNAMIC selections, so only a
// dynamic change affects them — refresh the *other* dynamics immediately
// (never the one that changed, which would refetch its own identical options).
// dynamic change affects them — refresh the *other* dynamics (never the one
// that changed, which would refetch its own identical options).
if (names.some((name) => variableTypes[name] === 'DYNAMIC')) {
dynamicVariableOrder
.filter((dynName) => !changed.has(dynName))
.forEach((dynName) => {
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = resolveFetchState(maps, dynName);
maps.states[dynName] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: VariableFetchState.Waiting;
});
}

View File

@@ -1,3 +1,5 @@
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
export enum VariableFetchState {
Idle = 'idle',
@@ -14,6 +16,11 @@ export interface FetchMaps {
cycleIds: Record<string, number>;
}
/** Settled = can make no further progress (idle or error). */
export function isSettled(state: VariableFetchState | undefined): boolean {
return state === VariableFetchState.Idle || state === VariableFetchState.Error;
}
/** Active = a fetch is in flight; only then should a settle be applied. */
export function isVariableInActiveFetchState(
state: VariableFetchState | undefined,
@@ -30,3 +37,25 @@ export function resolveFetchState(
? VariableFetchState.Revalidating
: VariableFetchState.Loading;
}
/** True once every QUERY variable is settled. */
export function areAllQueryVariablesSettled(
states: Record<string, VariableFetchState>,
variableTypes: Record<string, VariableType>,
): boolean {
return Object.entries(variableTypes)
.filter(([, type]) => type === 'QUERY')
.every(([name]) => isSettled(states[name]));
}
/** Move any `waiting` dynamic variables into loading/revalidating. */
export function unlockWaitingDynamicVariables(
maps: FetchMaps,
dynamicVariableOrder: string[],
): void {
dynamicVariableOrder.forEach((dynName) => {
if (maps.states[dynName] === VariableFetchState.Waiting) {
maps.states[dynName] = resolveFetchState(maps, dynName);
}
});
}

View File

@@ -26,6 +26,7 @@ import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useS
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
import styles from './PanelEditorPage.module.scss';
/**
@@ -95,10 +96,15 @@ function PanelEditorPage(): JSX.Element {
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
// variable selection from the persisted store, and time lives in Redux.
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [safeNavigate, dashboardId]);
// Carry only dashboard params; drop editor-only URL state (chiefly
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
'',
search,
)}`,
);
}, [safeNavigate, dashboardId, search]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -32,11 +32,12 @@ unaryExpression
;
// Primary constructs: grouped expressions, a comparison (key op value),
// a function call, or a full-text string
// a function call, a search() full-text call, or a free-text string
primary
: LPAREN orExpression RPAREN
| comparison
| functionCall
| searchCall
| fullText
| key
| value
@@ -110,6 +111,18 @@ functionCall
: (HASTOKEN | HAS | HASANY | HASALL) LPAREN functionParamList RPAREN
;
/*
* Full-text search call: search('needle')
*
* Uses the shared functionParamList so future scoped forms like
* search(body, 'abc') / search(attribute, 'abc') need no grammar change. Today
* only a single needle is supported. Unlike bare/quoted free text (`fullText`),
* which only targets the body column, search() fans out across every field.
*/
searchCall
: SEARCH LPAREN functionParamList RPAREN
;
// Function parameters can be keys, single scalar values, or arrays
functionParamList
: functionParam ( COMMA functionParam )*
@@ -184,6 +197,7 @@ HASTOKEN : [Hh][Aa][Ss][Tt][Oo][Kk][Ee][Nn];
HAS : [Hh][Aa][Ss] ;
HASANY : [Hh][Aa][Ss][Aa][Nn][Yy] ;
HASALL : [Hh][Aa][Ss][Aa][Ll][Ll] ;
SEARCH : [Ss][Ee][Aa][Rr][Cc][Hh] ;
// Potential boolean constants
BOOL

View File

@@ -98,7 +98,7 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
Description: "Returns all mappers belonging to a mapping group.",
Request: nil,
RequestContentType: "",
Response: new(spantypes.GettableSpanMapperGroups),
Response: new(spantypes.GettableSpanMappers),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -101,12 +101,12 @@ func PrepareFilterExpression(labels map[string]string, whereClause string, group
}
// Visit implements the visitor for the query rule.
func (r *WhereClauseRewriter) Visit(tree antlr.ParseTree) interface{} {
func (r *WhereClauseRewriter) Visit(tree antlr.ParseTree) any {
return tree.Accept(r)
}
// VisitQuery visits the query node.
func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) interface{} {
func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) any {
if ctx.Expression() != nil {
ctx.Expression().Accept(r)
}
@@ -114,7 +114,7 @@ func (r *WhereClauseRewriter) VisitQuery(ctx *parser.QueryContext) interface{} {
}
// VisitExpression visits the expression node.
func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) interface{} {
func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) any {
if ctx.OrExpression() != nil {
ctx.OrExpression().Accept(r)
}
@@ -122,7 +122,7 @@ func (r *WhereClauseRewriter) VisitExpression(ctx *parser.ExpressionContext) int
}
// VisitOrExpression visits OR expressions.
func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext) interface{} {
func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext) any {
for i, andExpr := range ctx.AllAndExpression() {
if i > 0 {
r.rewritten.WriteString(" OR ")
@@ -133,7 +133,7 @@ func (r *WhereClauseRewriter) VisitOrExpression(ctx *parser.OrExpressionContext)
}
// VisitAndExpression visits AND expressions.
func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContext) interface{} {
func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContext) any {
unaryExprs := ctx.AllUnaryExpression()
for i, unaryExpr := range unaryExprs {
if i > 0 {
@@ -151,7 +151,7 @@ func (r *WhereClauseRewriter) VisitAndExpression(ctx *parser.AndExpressionContex
}
// VisitUnaryExpression visits unary expressions (with optional NOT).
func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionContext) interface{} {
func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionContext) any {
if ctx.NOT() != nil {
r.rewritten.WriteString("NOT ")
}
@@ -162,7 +162,7 @@ func (r *WhereClauseRewriter) VisitUnaryExpression(ctx *parser.UnaryExpressionCo
}
// VisitPrimary visits primary expressions.
func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface{} {
func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) any {
if ctx.LPAREN() != nil && ctx.RPAREN() != nil {
r.rewritten.WriteString("(")
if ctx.OrExpression() != nil {
@@ -173,6 +173,8 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface
ctx.Comparison().Accept(r)
} else if ctx.FunctionCall() != nil {
ctx.FunctionCall().Accept(r)
} else if ctx.SearchCall() != nil {
ctx.SearchCall().Accept(r)
} else if ctx.FullText() != nil {
ctx.FullText().Accept(r)
} else if ctx.Key() != nil {
@@ -184,7 +186,7 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface
}
// VisitComparison visits comparison expressions.
func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) interface{} {
func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) any {
if ctx.Key() == nil {
return nil
}
@@ -197,7 +199,7 @@ func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) int
if _, partOfGroup := r.groupBySet[key]; partOfGroup {
// Case 1: Replace with actual value
escapedValue := escapeValueIfNeeded(value)
r.rewritten.WriteString(fmt.Sprintf("%s=%s", key, escapedValue))
fmt.Fprintf(&r.rewritten, "%s=%s", key, escapedValue)
return nil
}
}
@@ -305,7 +307,7 @@ func (r *WhereClauseRewriter) VisitComparison(ctx *parser.ComparisonContext) int
}
// VisitInClause visits IN clauses.
func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) interface{} {
func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) any {
r.rewritten.WriteString("IN ")
if ctx.LPAREN() != nil {
r.rewritten.WriteString("(")
@@ -326,7 +328,7 @@ func (r *WhereClauseRewriter) VisitInClause(ctx *parser.InClauseContext) interfa
}
// VisitNotInClause visits NOT IN clauses.
func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) interface{} {
func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) any {
r.rewritten.WriteString("NOT IN ")
if ctx.LPAREN() != nil {
r.rewritten.WriteString("(")
@@ -347,7 +349,7 @@ func (r *WhereClauseRewriter) VisitNotInClause(ctx *parser.NotInClauseContext) i
}
// VisitValueList visits value lists.
func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) interface{} {
func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) any {
values := ctx.AllValue()
for i, val := range values {
if i > 0 {
@@ -359,13 +361,20 @@ func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) inter
}
// VisitFullText visits full text expressions.
func (r *WhereClauseRewriter) VisitFullText(ctx *parser.FullTextContext) interface{} {
func (r *WhereClauseRewriter) VisitFullText(ctx *parser.FullTextContext) any {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitSearchCall visits search() calls. It has no keys to rewrite, so it is
// preserved verbatim.
func (r *WhereClauseRewriter) VisitSearchCall(ctx *parser.SearchCallContext) any {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitFunctionCall visits function calls.
func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext) interface{} {
func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext) any {
// Write function name
if ctx.HAS() != nil {
r.rewritten.WriteString("has")
@@ -386,7 +395,7 @@ func (r *WhereClauseRewriter) VisitFunctionCall(ctx *parser.FunctionCallContext)
}
// VisitFunctionParamList visits function parameter lists.
func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamListContext) interface{} {
func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamListContext) any {
params := ctx.AllFunctionParam()
for i, param := range params {
if i > 0 {
@@ -398,7 +407,7 @@ func (r *WhereClauseRewriter) VisitFunctionParamList(ctx *parser.FunctionParamLi
}
// VisitFunctionParam visits function parameters.
func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContext) interface{} {
func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContext) any {
if ctx.Key() != nil {
ctx.Key().Accept(r)
} else if ctx.Value() != nil {
@@ -410,7 +419,7 @@ func (r *WhereClauseRewriter) VisitFunctionParam(ctx *parser.FunctionParamContex
}
// VisitArray visits array expressions.
func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) interface{} {
func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) any {
r.rewritten.WriteString("[")
if ctx.ValueList() != nil {
ctx.ValueList().Accept(r)
@@ -420,13 +429,13 @@ func (r *WhereClauseRewriter) VisitArray(ctx *parser.ArrayContext) interface{} {
}
// VisitValue visits value expressions.
func (r *WhereClauseRewriter) VisitValue(ctx *parser.ValueContext) interface{} {
func (r *WhereClauseRewriter) VisitValue(ctx *parser.ValueContext) any {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitKey visits key expressions.
func (r *WhereClauseRewriter) VisitKey(ctx *parser.KeyContext) interface{} {
func (r *WhereClauseRewriter) VisitKey(ctx *parser.KeyContext) any {
r.keysSeen[ctx.GetText()] = struct{}{}
r.rewritten.WriteString(ctx.GetText())
return nil

View File

@@ -29,13 +29,19 @@ func New(t *testing.T) flagger.Flagger {
// WithUseJSONBody returns a Flagger with use_json_body set to the given value.
func WithUseJSONBody(t *testing.T, enabled bool) flagger.Flagger {
return WithBooleanFlags(t, map[string]bool{
flagger.FeatureUseJSONBody.String(): enabled,
})
}
// WithBooleanFlags returns a Flagger with the given boolean feature flags set to
// the provided values (keyed by feature name, e.g. flagger.FeatureX.String()).
func WithBooleanFlags(t *testing.T, flags map[string]bool) flagger.Flagger {
t.Helper()
registry := flagger.MustNewRegistry()
cfg := flagger.Config{}
if enabled {
cfg.Config.Boolean = map[string]bool{
flagger.FeatureUseJSONBody.String(): true,
}
if len(flags) > 0 {
cfg.Config.Boolean = flags
}
fl, err := flagger.New(
context.Background(),

View File

@@ -15,6 +15,7 @@ var (
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
FeatureAllowLogsSearch = featuretypes.MustNewName("allow_logs_search")
)
func MustNewRegistry() featuretypes.Registry {
@@ -115,6 +116,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureAllowLogsSearch,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the search() function is enabled for log queries",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
@@ -22,6 +23,7 @@ func newConditionBuilder(fm qbtypes.FieldMapper) qbtypes.ConditionBuilder {
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
@@ -31,7 +33,7 @@ func (c *conditionBuilder) ConditionFor(
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for rule state history.
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for rule state history.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
@@ -69,7 +71,7 @@ func (c *conditionBuilder) conditionForKey(
value = querybuilder.FormatValueForContains(value)
}
fieldName, err := c.fm.FieldFor(ctx, startNs, endNs, key)
fieldName, err := c.fm.FieldFor(ctx, valuer.UUID{}, startNs, endNs, key)
if err != nil {
return "", err
}

View File

@@ -8,6 +8,7 @@ import (
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
@@ -30,6 +31,12 @@ func newFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
// CandidateKeys returns nil: rule-state history has no attribute-map fallback, so a
// context-missing key stays unresolved and the caller errors.
func (m *fieldMapper) CandidateKeys(_ context.Context, _ valuer.UUID, _ *telemetrytypes.TelemetryFieldKey, _ any, _ map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
return nil
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) { //nolint:unparam
name := strings.TrimSpace(key.Name)
if col, ok := ruleStateHistoryColumns[name]; ok {
@@ -38,7 +45,7 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
return ruleStateHistoryColumns["labels"], nil
}
func (m *fieldMapper) FieldFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
col, err := m.getColumn(ctx, key)
if err != nil {
return "", err
@@ -49,7 +56,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _, _ uint64, key *telemetryt
return col.Name, nil
}
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
col, err := m.getColumn(ctx, key)
if err != nil {
return nil, err
@@ -57,8 +64,8 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetry
return []*schema.Column{col}, nil
}
func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, tsStart, tsEnd uint64, field *telemetrytypes.TelemetryFieldKey, _ map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
colName, err := m.FieldFor(ctx, tsStart, tsEnd, field)
func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, field *telemetrytypes.TelemetryFieldKey, _ map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
colName, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, field)
if err != nil {
return "", err
}

File diff suppressed because one or more lines are too long

View File

@@ -24,12 +24,13 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
'('=1
')'=2
'['=3

Some files were not shown because too many files have changed in this diff Show More