Compare commits

..

5 Commits

Author SHA1 Message Date
Ashwin Bhatkal
9fb89fafc8 feat(dashboard-v2): variable runtime — resolver, fetch engine, panel loading, impact dialog (#12125)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboard-v2): single default resolver for variable selection

One source of truth for a variable's value (seed → reconcile → payload), shared by the bar, the fetch gate and the query payload so they can't disagree. Precedence stays URL → store → default; ALL materializes once, without an extra round-trip.

* feat(dashboard-v2): dependency-ordered, value-gated variable fetch engine

Query roots + dynamics fetch immediately; query children unblock only once their parents hold a committed value, so a chain fetches each variable once. The cycle is idempotent per (dashboard·order·time) and resets on page unmount, so a re-mount or editor return can't double it or inherit stale state.

* refactor(dashboard-v2): consolidate variable selectors; add bar tooltip + loading bar

Fold the per-type Query/Dynamic/Custom selectors into one VariableValueControl backed by useVariableOptions. Add a hover tooltip showing a variable's dependencies (Depends on / Used by) and a loading bar flush along the control's bottom edge; refine name/dependency colors to design-system tokens.

* feat(dashboard-v2): hold panels in loading until referenced variables resolve

A panel waits until every QUERY/DYNAMIC variable it references is ready — a concrete pick and a DYNAMIC ALL are ready immediately, an unselected value or a QUERY ALL waits until it resolves. The wait folds into the panel's loading flags (a disabled query reports neither), so it never flashes "no data". Only referenced variables re-key the cache.

* feat(dashboard-v2): rename/delete variable impact dialog

Renaming or deleting a referenced variable is blocked behind a dialog listing every usage (QB / PromQL / ClickHouse panels and other variable defs), with a current → resulting preview, per-usage selection, and inline edits; applied as one JSON-Patch.

* fix(dashboard-v2): don't flash ALL on a multi-select while its options load

CustomMultiSelect derives "all selected" from options + the current value; with options still empty a concrete selection reads as covering everything. Only enable ALL detection once options have loaded.

* refactor(dashboard-v2): make the store the source of truth for variable selection

The persisted store (localStorage) is now the sole source of truth for runtime
variable values. Stop writing ?variables= on every selection change; the URL
param is read once on load (a share link) to seed the store and then dropped,
so dashboard navigation and drilldown no longer round-trip through the URL. The
panel editor reads the selection from the store (a lagging URL snapshot could
clobber a just-changed value). Drops the now-unused withVariablesSearch.

* refactor(dashboard-v2): address review feedback on the variable subsystem

- consolidate the default resolver: one configuredDefault normalizer shared by
  resolveDefaultSelection and configuredDefaultValue, plus a textDefault helper
  for TEXT (drops the dead textValue/empty-string fallbacks)
- reuse computeVariableDependencies for the bar tooltip's depends-on / used-by
- split the fetch machinery out of useVariableOptions into useFetchedVariableOptions
- extract useVariableListActions from the variables settings page
- reuse removeVariableFromExpression for delete-clause removal (handles dangling
  syntax / parentheses) and drop the local removeVariableReferenceClause
- grey the impact dialog's Current field on uncheck; comment + spacing nits;
  guard useIsPanelWaitingOnVariable so it reads variableTypes without optional chaining
- fix the seed test's read-once URL assertion
2026-07-16 08:54:38 +00:00
Abhi kumar
0655328fa9 feat(dashboard-v2): export panels & dashboards from explorers (#12043)
* feat(dashboard-v2): new-panel editor route + query-preserving seed

Add the /dashboard/:id/panel/new route helpers (buildExportPanelLink,
parseNewPanelKind, layoutIndex) and buildNewPanelSeed, which converts an
exported explorer compositeQuery into a seeded panel.

Coerces a builder-only kind (List) to a query-compatible one (PromQL ->
TimeSeries, ClickHouse -> Table) so a non-builder query is preserved rather
than dropped. PanelEditorPage consumes the effective kind; new List panels
seed columns from the query's resolved signal.

* feat(dashboard-v2): flag-aware export-to-dashboard hooks

Add the hooks shared by the explorer "Add to dashboard" flow:

- useGetExportToDashboardLink: builds the V2 panel-editor link (or the V1
  new-widget link) based on the use_dashboard_v2 flag.
- useExportDashboards: flag-aware, search-filtered dashboard source. V2
  searches server-side via the name-contains filter DSL (debounced); V1
  filters its already-complete list in memory.
- useCreateExportDashboard: flag-aware "create a new dashboard" - V2 via the
  Perses createDashboardV2, V1 via the legacy create - normalized to Dashboard.

* feat(dashboard-v2): rebuild "Add to dashboard" export dialog

Replace the antd Modal-based ExportPanel with a @signozhq/ui DialogWrapper
dialog driven by the export hooks. The picker searches server-side and pins the
selected dashboard as an option so it survives a narrowing search; "New
dashboard" creates via the flag-aware hook and navigates to the panel editor.

Removes the old index.tsx / styles.ts.

* feat(dashboard-v2): wire explorers to flag-aware export

Point the Logs/Traces/Metrics explorer "Add to dashboard" actions at
useGetExportToDashboardLink, so an exported query lands in the V2 panel editor
when use_dashboard_v2 is on (the V1 new-widget link otherwise). ExplorerOptions
now renders the rebuilt ExportPanel dialog directly instead of wrapping it in an
antd Modal.

* chore: fixed failing tests

* refactor(dashboard-v2): address explorer-export review feedback

- rename isQueryTypeSupported → isQueryTypeSupportedByPanelKind for clarity
- buildExportPanelLink returns null (no silent TimeSeries default) when a
  panel type has no V2 kind; explorers guard navigation on the null link
- introduce a neutral ExportDashboard {id,title} for the picker instead of
  reusing the V1 Dashboard entity with faked fields
- note why an exported Query's single unit doesn't seed columnUnits

* chore: pr review changes
2026-07-16 05:49:06 +00:00
Vinicius Lourenço
f24102c0db feat(infrastructure-monitoring-v2): pod status/counts & couple more fixes (#12127)
* refactor(infrastructure-monitoring-v2): remove pod phase and add pod status/counts

* fix(column-header): align to left

* fix(cell-value): ensure tooltip is correctly aligned

* fix(entity-traces): use - instead of N/A

* test(entity-traces): fix broken test
2026-07-16 03:34:12 +00:00
Vinicius Lourenço
edee102b52 feat(infrastructure-monitoring): add pod metrics tab (#12106)
* fix(k8s-base-details): do not allow untoggle tabs

* feat(constants): add base queries for all workloads

Ref: https://github.com/SigNoz/engineering-pod/issues/4032#issuecomment-4893103716

* feat(daemon-sets): add pod metrics tab

* feat(deployments): add pod metrics tab

* feat(jobs): add pod metrics tab

* feat(namespaces): add pod metrics tab

* feat(statefulsets): add pod metrics tab

* fix(k8s-base-details): do not keep selected invalid tab

* fix(pod-metrics): add new badge

* Revert "fix(pod-metrics): add new badge"

This reverts commit 09b8624f52.

* chore(k8s-base-details): mark tab as possible null
2026-07-16 03:30:33 +00:00
Srikanth Chekuri
9a26998d18 chore: add integration tests for metrics under reduction - query part (#11979)
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
* chore: add integration tests for metrics under reduction - query part

* chore: add to ci

* chore: add todos

* chore: update tests

* chore: trigger build

* chore: address review comments

* chore: undo the changes to tests/fixtures/http.py

* chore: remove __normalized and add fast path

* chore: fix unit tests

* chore: update test

* chore: sunset 25.5.6, update migrator version, and remove version arg

* chore: also update devenv

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:27:39 +00:00
135 changed files with 6532 additions and 1724 deletions

View File

@@ -1,6 +1,6 @@
services:
init-clickhouse:
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:25.12.5
container_name: init-clickhouse
command:
- bash
@@ -18,7 +18,7 @@ services:
volumes:
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
clickhouse:
image: clickhouse/clickhouse-server:25.5.6
image: clickhouse/clickhouse-server:25.12.5
container_name: clickhouse
volumes:
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
@@ -67,7 +67,7 @@ services:
timeout: 5s
retries: 3
telemetrystore-migrator:
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.144.6
container_name: telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -60,16 +60,17 @@ jobs:
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
- clickhousecluster
- metricreduction
sqlstore-provider:
- postgres
- sqlite
sqlite-mode:
- wal
clickhouse-version:
- 25.5.6
- 25.12.5
schema-migrator-version:
- v0.144.3
- v0.144.6
postgres-version:
- 15
if: |

View File

@@ -53,6 +53,7 @@ import {
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
@@ -66,7 +67,6 @@ import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMapp
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { FormattingOptions } from 'providers/preferences/types';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ViewProps } from 'types/api/saveViews/types';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -1031,26 +1031,19 @@ function ExplorerOptions({
/>
</div>
</Modal>
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
<ExportPanelContainer
open={isExport}
centered
destroyOnClose
>
<ExportPanelContainer
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</Modal>
onClose={onCancel(false)}
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</div>
);
}
@@ -1058,7 +1051,7 @@ function ExplorerOptions({
export interface ExplorerOptionsProps {
isLoading?: boolean;
onExport: (
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void;

View File

@@ -1,6 +1,7 @@
import { useHistory } from 'react-router-dom';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -80,7 +81,7 @@ const renderExplorerOptionWrapper = (
isLoading: false,
onExport: jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -150,7 +151,7 @@ describe('ExplorerOptionWrapper', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -179,15 +180,16 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the "New Dashboard" button
const newDashboardButton = screen.getByRole('button', {
name: /new dashboard/i,
});
// Click the "New dashboard" button
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
await user.click(newDashboardButton);
// Wait for the API call to complete and onExport to be called
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
expect(testOnExport).toHaveBeenCalledWith(
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
true,
);
});
});
@@ -195,7 +197,7 @@ describe('ExplorerOptionWrapper', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -229,13 +231,12 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for dashboards to load and then click on the dashboard select dropdown
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -251,19 +252,21 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard1.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the Export button to be enabled
// Wait for the selection to be made and the export button to be enabled
await waitFor(() => {
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
});
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
await user.click(exportButton);
// Wait for onExport to be called with the selected dashboard
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
expect(testOnExport).toHaveBeenCalledWith(
{ id: 'dashboard-1', title: 'Dashboard 1' },
false,
);
});
});
@@ -284,7 +287,7 @@ describe('ExplorerOptionWrapper', () => {
// Create a real handleExport function similar to LogsExplorerViews
// This should NOT call useUpdateDashboard (as per PR #8029)
const handleExport = (dashboard: Dashboard | null): void => {
const handleExport = (dashboard: ExportDashboard | null): void => {
if (!dashboard) {
return;
}
@@ -326,13 +329,12 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for dashboards to load and then click on the dashboard select dropdown
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -348,14 +350,13 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the Export button to be enabled
// Wait for the selection to be made and the export button to be enabled
await waitFor(() => {
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
});
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
await user.click(exportButton);
// Wait for the handleExport function to be called and navigation to occur
@@ -375,7 +376,7 @@ describe('ExplorerOptionWrapper', () => {
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void

View File

@@ -0,0 +1,74 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select, SelectProps } from 'antd';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { getSelectOptions } from './utils';
import styles from './ExportPanel.module.scss';
interface ExportDashboardSelectProps {
dashboards: ExportDashboard[];
value: string | null;
/** The picked dashboard, pinned as an option so its label survives a later search. */
selectedDashboard: ExportDashboard | null;
loading?: boolean;
disabled?: boolean;
onChange: (dashboardId: string) => void;
onSearch: (search: string) => void;
}
/**
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
* dropdown.
*/
function ExportDashboardSelect({
dashboards,
value,
selectedDashboard,
loading,
disabled,
onChange,
onSearch,
}: ExportDashboardSelectProps): JSX.Element {
const options = useMemo<SelectProps['options']>(() => {
const base = getSelectOptions(dashboards) ?? [];
if (
selectedDashboard &&
!base.some((option) => option.value === selectedDashboard.id)
) {
return [
{ label: selectedDashboard.title, value: selectedDashboard.id },
...base,
];
}
return base;
}, [dashboards, selectedDashboard]);
return (
<Select
className={styles.dashboardSelect}
placeholder="Select a dashboard"
showSearch
filterOption={false}
loading={loading}
disabled={disabled}
value={value}
onChange={onChange}
onSearch={onSearch}
data-testid="export-dashboard-select"
options={options}
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
/>
);
}
ExportDashboardSelect.defaultProps = {
loading: false,
disabled: false,
};
export default ExportDashboardSelect;

View File

@@ -0,0 +1,42 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--l2-foreground);
}
.dashboardSelect {
width: 100%;
}
.newDashboard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--l2-border);
}
.hint {
font-size: 13px;
color: var(--l2-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
}

View File

@@ -1,127 +1,159 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { Button } from 'antd';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import createDashboard from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ExportPanelProps } from '.';
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
import {
DashboardSelect,
NewDashboardButton,
SelectWrapper,
Title,
Wrapper,
} from './styles';
import { filterOptions, getSelectOptions } from './utils';
ExportDashboard,
useExportDashboards,
} from 'hooks/dashboard/useExportDashboards';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ExportDashboardSelect from './ExportDashboardSelect';
import styles from './ExportPanel.module.scss';
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
) => void;
query: Query | null;
/** Controlled open state of the dialog. */
open: boolean;
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
onClose: () => void;
}
/**
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
*/
function ExportPanelContainer({
isLoading,
onExport,
open,
onClose,
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
const [dashboardId, setDashboardId] = useState<string | null>(null);
// Track the object, not just the id, so export survives a search that narrows it out.
const [selectedDashboard, setSelectedDashboard] =
useState<ExportDashboard | null>(null);
const [searchText, setSearchText] = useState('');
const {
data,
dashboards,
isLoading: isAllDashboardsLoading,
refetch,
} = useGetAllDashboard();
isFetching: isDashboardsFetching,
} = useExportDashboards(searchText);
const { showErrorModal } = useErrorModal();
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
useMutation(createDashboard, {
onSuccess: (data) => {
if (data.data) {
onExport(data?.data, true);
}
refetch();
},
onError: (error) => {
showErrorModal(error as APIError);
},
const { create: createNewDashboard, isLoading: createDashboardLoading } =
useCreateExportDashboard({
title: t('new_dashboard_title', { ns: 'dashboard' }),
onCreated: (dashboard) => onExport(dashboard, true),
});
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
const handleExportClick = useCallback((): void => {
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === dashboardId,
);
onExport(currentSelectedDashboard || null, false);
}, [data, dashboardId, onExport]);
// Reset on close so each open starts fresh (the dialog stays mounted).
useEffect(() => {
if (!open) {
setSelectedDashboard(null);
setSearchText('');
}
}, [open]);
const handleSelect = useCallback(
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
(dashboardId: string): void => {
setSelectedDashboard(
dashboards.find(({ id }) => id === dashboardId) ?? null,
);
},
[setDashboardId],
[dashboards],
);
const handleNewDashboard = useCallback(async () => {
try {
await createNewDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
} catch (error) {
showErrorModal(error as APIError);
}
}, [createNewDashboard, t, showErrorModal]);
const handleExportClick = useCallback((): void => {
onExport(selectedDashboard, false);
}, [selectedDashboard, onExport]);
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
const isExportDisabled =
isAllDashboardsLoading || !selectedDashboard || isLoading;
return (
<Wrapper direction="vertical">
<Title>Export Panel</Title>
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Add to dashboard"
testId="export-panel-dialog"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={onClose}
testId="export-panel-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
loading={isLoading}
disabled={isExportDisabled}
onClick={handleExportClick}
testId="export-panel-export"
>
Add to dashboard
</Button>
</div>
}
>
<div className={styles.body}>
<div className={styles.field}>
<Typography.Text className={styles.label}>
Select a dashboard
</Typography.Text>
<ExportDashboardSelect
dashboards={dashboards}
value={selectedDashboard?.id ?? null}
selectedDashboard={selectedDashboard}
loading={isDashboardsFetching}
disabled={isAllDashboardsLoading || createDashboardLoading}
onChange={handleSelect}
onSearch={setSearchText}
/>
</div>
<SelectWrapper direction="horizontal">
<DashboardSelect
placeholder="Select Dashboard"
options={options}
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={dashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>
<Button
type="primary"
loading={isLoading}
disabled={isDisabled}
onClick={handleExportClick}
>
Export
</Button>
</SelectWrapper>
<Typography>
Or create dashboard with this panel -
<NewDashboardButton
disabled={createDashboardLoading}
loading={createDashboardLoading}
type="link"
onClick={handleNewDashboard}
>
New Dashboard
</NewDashboardButton>
</Typography>
</Wrapper>
<div className={styles.newDashboard}>
<Typography.Text className={styles.hint}>
Or create a new dashboard with this panel
</Typography.Text>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<Plus size={14} />}
loading={createDashboardLoading}
disabled={createDashboardLoading}
onClick={createNewDashboard}
testId="export-panel-new-dashboard"
>
New dashboard
</Button>
</div>
</div>
</DialogWrapper>
);
}
ExportPanelContainer.defaultProps = {
isLoading: false,
};
export default ExportPanelContainer;

View File

@@ -1,49 +0,0 @@
import { useCallback, useState } from 'react';
import { Modal } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ExportPanelContainer from './ExportPanelContainer';
function ExportPanel({
isLoading,
onExport,
query,
}: ExportPanelProps): JSX.Element {
const [isExport, setIsExport] = useState<boolean>(false);
const onModalToggle = useCallback((value: boolean) => {
setIsExport(value);
}, []);
const onCancel = (value: boolean) => (): void => {
onModalToggle(value);
};
return (
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
centered
destroyOnClose
>
<ExportPanelContainer
query={query}
isLoading={isLoading}
onExport={onExport}
/>
</Modal>
);
}
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
query: Query | null;
}
ExportPanel.defaultProps = { isLoading: false };
export default ExportPanel;

View File

@@ -1,34 +0,0 @@
import { FunctionComponent } from 'react';
import { Button, Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
Select,
)<SelectProps>`
width: 100%;
`;
export const SelectWrapper = styled(Space)`
width: 100%;
margin-bottom: 1rem;
.ant-space-item:first-child {
width: 100%;
max-width: 20rem;
}
`;
export const Wrapper = styled(Space)`
width: 100%;
`;
export const NewDashboardButton = styled(Button)`
&&& {
padding: 0 0.125rem;
}
`;
export const Title = styled(Typography.Text)`
font-size: 1rem;
`;

View File

@@ -1,16 +1,10 @@
import { SelectProps } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
data.map(({ id, data }) => ({
label: data.title,
export const getSelectOptions = (
data: ExportDashboard[],
): SelectProps['options'] =>
data.map(({ id, title }) => ({
label: title,
value: id,
}));
export const filterOptions: SelectProps['filterOption'] = (
input,
options,
): boolean =>
(options?.label?.toString() ?? '')
?.toLowerCase()
.includes(input.toLowerCase());

View File

@@ -11,8 +11,6 @@ import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/const
import { CellValueTooltip } from 'container/InfraMonitoringK8sV2/components';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import TanStackTable from 'components/TanStackTableView';
const HOSTNAME_DOCS_URL =
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
@@ -23,11 +21,7 @@ export function HostnameCell({
}): React.ReactElement {
const isEmpty = !hostName || !hostName.trim();
if (!isEmpty) {
return (
<CellValueTooltip value={hostName}>
<TanStackTable.Text>{hostName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={hostName} />;
}
return (
<>

View File

@@ -11,6 +11,6 @@
}
.columnHeaderLabel {
text-align: center;
text-align: left;
padding: var(--spacing-2) var(--spacing-2) var(--spacing-2) 0px;
}

View File

@@ -86,6 +86,23 @@ export interface K8sDetailsFilters {
end: number;
}
export interface CustomTabRenderProps<T> {
entity: T;
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
export interface CustomTab<T> {
key: string;
label: string;
icon: React.ReactNode;
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
@@ -122,20 +139,7 @@ export interface K8sBaseDetailsProps<T> {
showTraces?: boolean;
showEvents?: boolean;
};
customTabs?: Array<{
key: string;
label: string;
icon: React.ReactNode;
render: (props: {
entity: T;
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}) => React.ReactNode;
}>;
customTabs?: Array<CustomTab<T>>;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -271,6 +275,33 @@ export default function K8sBaseDetails<T>({
const [selectedView, setSelectedView] = useInfraMonitoringView();
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
if (tabVisibility.showLogs) {
tabs.push(VIEW_TYPES.LOGS);
}
if (tabVisibility.showTraces) {
tabs.push(VIEW_TYPES.TRACES);
}
if (tabVisibility.showEvents) {
tabs.push(VIEW_TYPES.EVENTS);
}
if (customTabs) {
tabs.push(...customTabs.map((t) => t.key));
}
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
@@ -306,7 +337,10 @@ export default function K8sBaseDetails<T>({
}
}, [getMinMaxTime, selectedTime]);
const handleTabChange = (value: string): void => {
const handleTabChange = (value: string | null): void => {
if (!value) {
return;
}
setSelectedView(value);
setLogFiltersParam(null);
setTracesFiltersParam(null);

View File

@@ -0,0 +1,221 @@
import { Box } from '@signozhq/icons';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, waitFor } from 'tests/test-utils';
import {
InfraMonitoringEntity,
INFRA_MONITORING_K8S_PARAMS_KEYS,
VIEW_TYPES,
} from '../../constants';
import K8sBaseDetails from '../K8sBaseDetails';
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="mock-datetime" />,
}));
type TestEntity = {
name: string;
namespace: string;
cluster: string;
};
const mockEntity: TestEntity = {
name: 'test-pod',
namespace: 'default',
cluster: 'test-cluster',
};
function createBaseProps() {
return {
category: InfraMonitoringEntity.PODS,
eventCategory: 'Pod',
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
fetchEntityData: jest
.fn()
.mockResolvedValue({ data: mockEntity, error: null }),
getEntityName: (e: TestEntity): string => e.name,
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
metadataConfig: [
{ label: 'Name', getValue: (e: TestEntity): string => e.name },
],
entityWidgetInfo: [{ title: 'CPU', yAxisUnit: 'percent' }],
getEntityQueryPayload: jest.fn().mockReturnValue([]),
queryKeyPrefix: 'testPod',
};
}
interface RenderOptions {
view?: string;
tabsConfig?: {
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
};
customTabs?: Array<{
key: string;
label: string;
icon: React.ReactNode;
render: () => React.ReactNode;
}>;
}
function renderK8sBaseDetails({
view = VIEW_TYPES.METRICS,
tabsConfig,
customTabs,
}: RenderOptions = {}) {
const searchParams: Record<string, string> = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'test-pod',
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
};
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<K8sBaseDetails<TestEntity>
{...createBaseProps()}
tabsConfig={tabsConfig}
customTabs={customTabs}
/>
</NuqsTestingAdapter>,
);
}
function getSelectedTabText(): string | null {
const selectedTab = document.querySelector('[aria-checked="true"]');
return selectedTab?.textContent ?? null;
}
describe('K8sBaseDetails - Tab Validation', () => {
it('should reset view to METRICS when selected view is invalid', async () => {
act(() => {
renderK8sBaseDetails({ view: 'invalid-tab' });
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Metrics');
});
});
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
act(() => {
renderK8sBaseDetails({
view: 'invalid-tab',
tabsConfig: { showMetrics: false },
});
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Logs');
});
});
it('should reset to custom tab when all standard tabs disabled and custom tab exists', async () => {
const customTabKey = 'pod-metrics';
act(() => {
renderK8sBaseDetails({
view: 'invalid-tab',
tabsConfig: {
showMetrics: false,
showLogs: false,
showTraces: false,
showEvents: false,
},
customTabs: [
{
key: customTabKey,
label: 'Pod Metrics',
icon: <Box size={14} />,
render: (): React.ReactNode => <div>Custom Tab</div>,
},
],
});
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Pod Metrics');
});
});
it('should NOT reset view when selected view is valid', async () => {
act(() => {
renderK8sBaseDetails({ view: VIEW_TYPES.LOGS });
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Logs');
});
});
it('should NOT reset view when custom tab is selected and exists', async () => {
const customTabKey = 'pod-metrics';
act(() => {
renderK8sBaseDetails({
view: customTabKey,
customTabs: [
{
key: customTabKey,
label: 'Pod Metrics',
icon: <Box size={14} />,
render: (): React.ReactNode => <div>Custom Tab</div>,
},
],
});
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Pod Metrics');
});
});
it('should keep the selected tab when the active tab is clicked again (untoggle guard)', async () => {
const user = userEvent.setup();
act(() => {
renderK8sBaseDetails({ view: VIEW_TYPES.LOGS });
});
await waitFor(() => {
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(getSelectedTabText()).toContain('Logs');
});
const selectedTab = document.querySelector('[aria-checked="true"]');
expect(selectedTab).not.toBeNull();
await user.click(selectedTab as Element);
await waitFor(() => {
expect(getSelectedTabText()).toContain('Logs');
});
});
});

View File

@@ -8,7 +8,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
GroupedStatusCounts,
@@ -77,11 +77,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return (
<CellValueTooltip value={clusterName}>
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={clusterName} />;
},
},
{
@@ -121,23 +117,25 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
},
},
{
id: 'podCountsByPhase',
id: 'podCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/clusters#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (row): InframonitoringtypesClusterRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
accessorFn: (
row,
): InframonitoringtypesClusterRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(row.podCountsByPhase)} />
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
);
},
},

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listDaemonSets } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
@@ -10,6 +10,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { InfraMonitoringEvents } from 'constants/events';
import APIError from 'types/api/error';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
@@ -18,6 +19,7 @@ import { SelectedItemParams } from '../hooks';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
getDaemonSetPodMetricsQueryPayload,
k8sDaemonSetDetailsMetadataConfig,
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemExpression,
@@ -29,6 +31,8 @@ import {
getK8sDaemonSetRowKey,
k8sDaemonSetsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sDaemonSetsList({
controlListPrefix,
}: {
@@ -112,6 +116,17 @@ function K8sDaemonSetsList({
},
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesDaemonSetRecordDTO>({
getQueryPayload: getDaemonSetPodMetricsQueryPayload,
category: InfraMonitoringEntity.DAEMONSETS,
queryKey: 'daemonSetPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO, SelectedItemParams>
@@ -135,6 +150,7 @@ function K8sDaemonSetsList({
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
queryKeyPrefix="daemonset"
customTabs={customTabs}
/>
</>
);

View File

@@ -7,7 +7,10 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
@@ -676,3 +679,29 @@ export const getDaemonSetMetricsQueryPayload = (
},
];
};
export const getDaemonSetPodMetricsQueryPayload = (
daemonSet: InframonitoringtypesDaemonSetRecordDTO,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sDaemonSetNameKey = dotMetricsEnabled
? 'k8s.daemonset.name'
: 'k8s_daemonset_name';
return getPodUtilizationByPodQueryPayloads(
{
workloadNameKey: k8sDaemonSetNameKey,
workloadNameValue:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '',
clusterName:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
namespaceName:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
start,
end,
dotMetricsEnabled,
);
};

View File

@@ -7,7 +7,7 @@ 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 { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
@@ -87,11 +87,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return (
<CellValueTooltip value={daemonsetName}>
<TanStackTable.Text>{daemonsetName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={daemonsetName} />;
},
},
{
@@ -108,35 +104,29 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<CellValueTooltip value={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={namespaceName} />;
},
},
{
id: 'pod_counts_by_phase',
id: 'pod_counts_by_status',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesDaemonSetRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
): InframonitoringtypesDaemonSetRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(podCountsByPhase)} />
);
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
},
},
{

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listDeployments } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
@@ -19,6 +19,7 @@ import { SelectedItemParams } from '../hooks';
import {
deploymentWidgetInfo,
getDeploymentMetricsQueryPayload,
getDeploymentPodMetricsQueryPayload,
k8sDeploymentDetailsMetadataConfig,
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemExpression,
@@ -30,6 +31,7 @@ import {
getK8sDeploymentRowKey,
k8sDeploymentsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sDeploymentsList({
controlListPrefix,
@@ -118,6 +120,17 @@ function K8sDeploymentsList({
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesDeploymentRecordDTO>({
getQueryPayload: getDeploymentPodMetricsQueryPayload,
category: InfraMonitoringEntity.DEPLOYMENTS,
queryKey: 'deploymentPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO, SelectedItemParams>
@@ -142,6 +155,7 @@ function K8sDeploymentsList({
entityWidgetInfo={deploymentWidgetInfo}
getEntityQueryPayload={getDeploymentMetricsQueryPayload}
queryKeyPrefix="deployment"
customTabs={customTabs}
/>
</>
);

View File

@@ -7,7 +7,10 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
@@ -675,3 +678,29 @@ export const getDeploymentMetricsQueryPayload = (
},
];
};
export const getDeploymentPodMetricsQueryPayload = (
deployment: InframonitoringtypesDeploymentRecordDTO,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sDeploymentNameKey = dotMetricsEnabled
? 'k8s.deployment.name'
: 'k8s_deployment_name';
return getPodUtilizationByPodQueryPayloads(
{
workloadNameKey: k8sDeploymentNameKey,
workloadNameValue:
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
clusterName:
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
namespaceName:
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
start,
end,
dotMetricsEnabled,
);
};

View File

@@ -7,7 +7,7 @@ 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 { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
@@ -88,11 +88,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return (
<CellValueTooltip value={deploymentName}>
<TanStackTable.Text>{deploymentName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={deploymentName} />;
},
},
{
@@ -112,24 +108,22 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
),
},
{
id: 'podCountsByPhase',
id: 'podCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (row): object | undefined => row.podCountsByPhase,
accessorFn: (row): object | undefined => row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(podCountsByPhase)} />
);
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
},
},
{

View File

@@ -87,7 +87,7 @@ describe('EntityTraces - Table Rendering', () => {
expect(badge).toHaveAttribute('data-variant', 'outline');
});
it('should render N/A when http method is empty', async () => {
it('should render - when http method is empty', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: '', responseStatusCode: '200' }],
});
@@ -96,7 +96,7 @@ describe('EntityTraces - Table Rendering', () => {
renderEntityTraces();
});
await expect(screen.findByText('N/A')).resolves.toBeInTheDocument();
await expect(screen.findByText('-')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('httpMethod')).not.toBeInTheDocument();
});

View File

@@ -4,4 +4,8 @@
.cellText {
color: var(--l2-foreground);
&[data-novalue='true'] {
opacity: 0.6;
}
}

View File

@@ -89,8 +89,12 @@ export const getTraceListColumns = (
if (value === '') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
N/A
<Typography
data-testid={key}
className={styles.cellText}
data-novalue="true"
>
-
</Typography>
</BlockLink>
);
@@ -102,7 +106,9 @@ export const getTraceListColumns = (
if (!httpMethod) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>N/A</Typography>
<Typography className={styles.cellText} data-novalue="true">
-
</Typography>
</BlockLink>
);
}
@@ -129,8 +135,11 @@ export const getTraceListColumns = (
if (!isValidCode) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>
{numericCode === 0 || !statusCode ? 'N/A' : statusCode}
<Typography
className={styles.cellText}
data-novalue={numericCode === 0 || !statusCode}
>
{numericCode === 0 || !statusCode ? '-' : statusCode}
</Typography>
</BlockLink>
);

View File

@@ -0,0 +1,47 @@
import { Container } from '@signozhq/icons';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
import {
InfraMonitoringEntity,
podUtilizationByPodWidgetInfo,
VIEW_TYPES,
} from '../constants';
import EntityMetrics from './EntityMetrics';
interface CreatePodMetricsTabParams<T> {
getQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
category: InfraMonitoringEntity;
queryKey: string;
}
export function createPodMetricsTab<T>({
getQueryPayload,
category,
queryKey,
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
return {
key: VIEW_TYPES.POD_METRICS,
label: 'Pod Metrics',
icon: <Container size={14} />,
render: ({ entity, timeRange, selectedInterval, handleTimeChange }) => (
<EntityMetrics
entity={entity}
selectedInterval={selectedInterval}
timeRange={timeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={podUtilizationByPodWidgetInfo}
getEntityQueryPayload={getQueryPayload}
category={category}
queryKey={queryKey}
/>
),
};
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listJobs } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
@@ -18,6 +18,7 @@ import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getJobMetricsQueryPayload,
getJobPodMetricsQueryPayload,
jobWidgetInfo,
k8sJobDetailsMetadataConfig,
k8sJobGetEntityName,
@@ -30,6 +31,7 @@ import {
getK8sJobRowKey,
k8sJobsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sJobsList({
controlListPrefix,
@@ -118,6 +120,17 @@ function K8sJobsList({
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesJobRecordDTO>({
getQueryPayload: getJobPodMetricsQueryPayload,
category: InfraMonitoringEntity.JOBS,
queryKey: 'jobPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesJobRecordDTO, SelectedItemParams>
@@ -142,6 +155,7 @@ function K8sJobsList({
entityWidgetInfo={jobWidgetInfo}
getEntityQueryPayload={getJobMetricsQueryPayload}
queryKeyPrefix="job"
customTabs={customTabs}
/>
</>
);

View File

@@ -7,7 +7,10 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
@@ -429,3 +432,25 @@ export const getJobMetricsQueryPayload = (
},
];
};
export const getJobPodMetricsQueryPayload = (
job: InframonitoringtypesJobRecordDTO,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sJobNameKey = dotMetricsEnabled ? 'k8s.job.name' : 'k8s_job_name';
return getPodUtilizationByPodQueryPayloads(
{
workloadNameKey: k8sJobNameKey,
workloadNameValue: job.jobName ?? '',
clusterName: job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
namespaceName:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
start,
end,
dotMetricsEnabled,
);
};

View File

@@ -7,7 +7,7 @@ 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 { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
@@ -81,11 +81,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const jobName = value as string;
return (
<CellValueTooltip value={jobName}>
<TanStackTable.Text>{jobName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={jobName} />;
},
},
{
@@ -102,33 +98,27 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<CellValueTooltip value={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={namespaceName} />;
},
},
{
id: 'pod_counts_by_phase',
id: 'pod_counts_by_status',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (row): InframonitoringtypesJobRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
accessorFn: (row): InframonitoringtypesJobRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(podCountsByPhase)} />
);
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
},
},
{

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listNamespaces } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
@@ -18,6 +18,7 @@ import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getNamespaceMetricsQueryPayload,
getNamespacePodMetricsQueryPayload,
k8sNamespaceDetailsCountsConfig,
k8sNamespaceDetailsMetadataConfig,
k8sNamespaceGetCountsFilterExpression,
@@ -32,6 +33,7 @@ import {
getK8sNamespaceRowKey,
k8sNamespacesColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sNamespacesList({
controlListPrefix,
@@ -120,6 +122,17 @@ function K8sNamespacesList({
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesNamespaceRecordDTO>({
getQueryPayload: getNamespacePodMetricsQueryPayload,
category: InfraMonitoringEntity.NAMESPACES,
queryKey: 'namespacePodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesNamespaceRecordDTO, SelectedItemParams>
@@ -146,6 +159,7 @@ function K8sNamespacesList({
entityWidgetInfo={namespaceWidgetInfo}
getEntityQueryPayload={getNamespaceMetricsQueryPayload}
queryKeyPrefix="namespace"
customTabs={customTabs}
/>
</>
);

View File

@@ -11,6 +11,7 @@ import {
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
@@ -1752,3 +1753,26 @@ export const getNamespaceMetricsQueryPayload = (
},
];
};
export const getNamespacePodMetricsQueryPayload = (
namespace: InframonitoringtypesNamespaceRecordDTO,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
return getPodUtilizationByPodQueryPayloads(
{
workloadNameKey: k8sNamespaceNameKey,
workloadNameValue: namespace.namespaceName ?? '',
clusterName:
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
},
start,
end,
dotMetricsEnabled,
);
};

View File

@@ -6,7 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
GroupedStatusCounts,
@@ -83,11 +83,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<CellValueTooltip value={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={namespaceName} />;
},
},
{
@@ -106,25 +102,25 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
),
},
{
id: 'podCountsByPhase',
id: 'podCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/namespaces#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/namespaces#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesNamespaceRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
): InframonitoringtypesNamespaceRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(row.podCountsByPhase)} />
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
);
},
},

View File

@@ -7,7 +7,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
CellValueTooltip,
@@ -85,11 +85,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const nodeName = value as string;
return (
<CellValueTooltip value={nodeName}>
<TanStackTable.Text>{nodeName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={nodeName} />;
},
},
{
@@ -132,23 +128,23 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
},
},
{
id: 'podCountsByPhase',
id: 'podCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/nodes#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/nodes#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (row): InframonitoringtypesNodeRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
accessorFn: (row): InframonitoringtypesNodeRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(row.podCountsByPhase)} />
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
);
},
},

View File

@@ -1,9 +1,9 @@
import { Container } from '@signozhq/icons';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import {
InframonitoringtypesPodPhaseDTO,
InframonitoringtypesPodRecordDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
@@ -11,7 +11,11 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import {
formatBytes,
getPodStatusItems,
POD_STATUS_COLORS,
} from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
@@ -40,15 +44,6 @@ export function getK8sPodItemKey(
return pod.podUID;
}
const POD_PHASE_COLORS: Record<string, BadgeColor> = {
running: 'forest',
pending: 'amber',
succeeded: 'robin',
failed: 'cherry',
unknown: 'vanilla',
no_data: 'vanilla',
};
export type PodTableColumnConfig =
TableColumnDef<InframonitoringtypesPodRecordDTO>;
export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
@@ -93,34 +88,30 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const podName = value as string;
return (
<CellValueTooltip value={podName}>
<TanStackTable.Text>{podName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={podName} />;
},
},
{
id: 'podPhase',
id: 'podStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#pod-phase">
Phase
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#pod-status">
Status
</ColumnHeader>
),
accessorFn: (row): string => row.podPhase,
width: { min: 120 },
accessorFn: (row): string => row.podStatus,
width: { min: 160 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
if (!row.podPhase) {
if (!row.podStatus) {
return <></>;
}
const color = POD_PHASE_COLORS[row.podPhase] || POD_PHASE_COLORS.unknown;
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
const label =
row.podPhase === InframonitoringtypesPodPhaseDTO.no_data
row.podStatus === InframonitoringtypesPodStatusDTO.no_data
? 'No Data'
: row.podPhase.charAt(0).toUpperCase() + row.podPhase.slice(1);
: row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
return (
<Badge color={color} variant="outline">
{label}
@@ -129,24 +120,24 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
},
},
{
id: 'podCountsByPhase',
id: 'podCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#pod-phase">
Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#pod-status">
Status
</ColumnHeader>
),
accessorFn: (row): InframonitoringtypesPodRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
accessorFn: (row): InframonitoringtypesPodRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(row.podCountsByPhase)} />
<GroupedStatusCounts items={getPodStatusItems(row.podCountsByStatus)} />
);
},
},
@@ -172,6 +163,28 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
return <TanStackTable.Text>{formatAge(age)}</TanStackTable.Text>;
},
},
{
id: 'podRestarts',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#restarts">
Restarts
</ColumnHeader>
),
accessorFn: (row): number => row.podRestarts,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const restarts = value as number;
if (restarts === -1) {
return (
<TooltipSimple title="No data">
<Typography.Text>-</Typography.Text>
</TooltipSimple>
);
}
return <TanStackTable.Text>{restarts}</TanStackTable.Text>;
},
},
{
id: 'cpu_request',
header: (): React.ReactNode => (

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listStatefulSets } from 'api/generated/services/inframonitoring';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
@@ -18,6 +18,7 @@ import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getStatefulSetMetricsQueryPayload,
getStatefulSetPodMetricsQueryPayload,
k8sStatefulSetDetailsMetadataConfig,
k8sStatefulSetGetEntityName,
k8sStatefulSetGetSelectedItemExpression,
@@ -30,6 +31,7 @@ import {
getK8sStatefulSetRowKey,
k8sStatefulSetsColumnsConfig,
} from './table.config';
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
function K8sStatefulSetsList({
controlListPrefix,
@@ -118,6 +120,17 @@ function K8sStatefulSetsList({
[],
);
const customTabs = useMemo(
() => [
createPodMetricsTab<InframonitoringtypesStatefulSetRecordDTO>({
getQueryPayload: getStatefulSetPodMetricsQueryPayload,
category: InfraMonitoringEntity.STATEFULSETS,
queryKey: 'statefulSetPodMetrics',
}),
],
[],
);
return (
<>
<K8sBaseList<InframonitoringtypesStatefulSetRecordDTO, SelectedItemParams>
@@ -142,6 +155,7 @@ function K8sStatefulSetsList({
entityWidgetInfo={statefulSetWidgetInfo}
getEntityQueryPayload={getStatefulSetMetricsQueryPayload}
queryKeyPrefix="statefulSet"
customTabs={customTabs}
/>
</>
);

View File

@@ -7,7 +7,10 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
getPodUtilizationByPodQueryPayloads,
INFRA_MONITORING_ATTR_KEYS,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
@@ -859,3 +862,29 @@ export const getStatefulSetMetricsQueryPayload = (
},
];
};
export const getStatefulSetPodMetricsQueryPayload = (
statefulSet: InframonitoringtypesStatefulSetRecordDTO,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sStatefulSetNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
: 'k8s_statefulset_name';
return getPodUtilizationByPodQueryPayloads(
{
workloadNameKey: k8sStatefulSetNameKey,
workloadNameValue:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
clusterName:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
namespaceName:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
start,
end,
dotMetricsEnabled,
);
};

View File

@@ -7,7 +7,7 @@ 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 { formatBytes, getPodStatusItems } from '../commonUtils';
import {
CellValueTooltip,
EntityProgressBar,
@@ -88,11 +88,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const statefulsetName = value as string;
return (
<CellValueTooltip value={statefulsetName}>
<TanStackTable.Text>{statefulsetName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={statefulsetName} />;
},
},
{
@@ -109,35 +105,29 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<CellValueTooltip value={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={namespaceName} />;
},
},
{
id: 'pod_counts_by_phase',
id: 'pod_counts_by_status',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#pod-counts-by-phase">
Pod Phases
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#pod-counts-by-status">
Pod Status
</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesStatefulSetRecordDTO['podCountsByPhase'] =>
row.podCountsByPhase,
): InframonitoringtypesStatefulSetRecordDTO['podCountsByStatus'] =>
row.podCountsByStatus,
width: { min: 250 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => {
const podCountsByPhase = row.podCountsByPhase;
if (!podCountsByPhase) {
const podCountsByStatus = row.podCountsByStatus;
if (!podCountsByStatus) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<GroupedStatusCounts items={getPodPhaseStatusItems(podCountsByPhase)} />
);
return <GroupedStatusCounts items={getPodStatusItems(podCountsByStatus)} />;
},
},
{

View File

@@ -81,11 +81,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const pvcName = value as string;
return (
<CellValueTooltip value={pvcName}>
<TanStackTable.Text>{pvcName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={pvcName} />;
},
},
{
@@ -101,11 +97,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<CellValueTooltip value={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</CellValueTooltip>
);
return <CellValueTooltip value={namespaceName} />;
},
},
{

View File

@@ -1,5 +1,9 @@
import { Color } from '@signozhq/design-tokens';
import { InframonitoringtypesPodCountsByPhaseDTO } from 'api/generated/services/sigNoz.schemas';
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesPodCountsByStatusDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { StatusCountItem } from './components/GroupedStatusCounts';
@@ -64,17 +68,106 @@ export function getStrokeColorForLimitUtilization(value: number): string {
return Color.BG_SAKURA_500;
}
/**
* Builds StatusCountItem[] for GroupedStatusCounts from pod phase counts.
*/
export function getPodPhaseStatusItems(
counts: InframonitoringtypesPodCountsByPhaseDTO,
export const POD_STATUS_COLORS: Record<
InframonitoringtypesPodStatusDTO,
BadgeColor
> = {
[InframonitoringtypesPodStatusDTO.running]: 'forest',
[InframonitoringtypesPodStatusDTO.completed]: 'robin',
[InframonitoringtypesPodStatusDTO.pending]: 'amber',
[InframonitoringtypesPodStatusDTO.unknown]: 'vanilla',
[InframonitoringtypesPodStatusDTO.no_data]: 'vanilla',
[InframonitoringtypesPodStatusDTO.failed]: 'cherry',
[InframonitoringtypesPodStatusDTO.crashloopbackoff]: 'cherry',
[InframonitoringtypesPodStatusDTO.imagepullbackoff]: 'cherry',
[InframonitoringtypesPodStatusDTO.errimagepull]: 'cherry',
[InframonitoringtypesPodStatusDTO.createcontainerconfigerror]: 'cherry',
[InframonitoringtypesPodStatusDTO.containercreating]: 'amber',
[InframonitoringtypesPodStatusDTO.oomkilled]: 'cherry',
[InframonitoringtypesPodStatusDTO.error]: 'cherry',
[InframonitoringtypesPodStatusDTO.containercannotrun]: 'cherry',
[InframonitoringtypesPodStatusDTO.evicted]: 'cherry',
[InframonitoringtypesPodStatusDTO.nodeaffinity]: 'cherry',
[InframonitoringtypesPodStatusDTO.nodelost]: 'cherry',
[InframonitoringtypesPodStatusDTO.shutdown]: 'cherry',
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]: 'cherry',
};
type PodStatusCategory =
| 'running'
| 'completed'
| 'pending'
| 'unknown'
| 'error';
const POD_STATUS_CATEGORY_MAP: Record<
keyof InframonitoringtypesPodCountsByStatusDTO,
PodStatusCategory
> = {
running: 'running',
completed: 'completed',
pending: 'pending',
unknown: 'unknown',
failed: 'error',
crashLoopBackOff: 'error',
imagePullBackOff: 'error',
errImagePull: 'error',
createContainerConfigError: 'error',
containerCreating: 'error',
oomKilled: 'error',
error: 'error',
containerCannotRun: 'error',
evicted: 'error',
nodeAffinity: 'error',
nodeLost: 'error',
shutdown: 'error',
unexpectedAdmissionError: 'error',
};
type ErrorStatusKey = {
[K in keyof InframonitoringtypesPodCountsByStatusDTO]: (typeof POD_STATUS_CATEGORY_MAP)[K] extends 'error'
? K
: never;
}[keyof InframonitoringtypesPodCountsByStatusDTO];
const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {
failed: 'Failed',
crashLoopBackOff: 'CrashLoopBackOff',
imagePullBackOff: 'ImagePullBackOff',
errImagePull: 'ErrImagePull',
createContainerConfigError: 'CreateContainerConfigError',
containerCreating: 'ContainerCreating',
oomKilled: 'OOMKilled',
error: 'Error',
containerCannotRun: 'ContainerCannotRun',
evicted: 'Evicted',
nodeAffinity: 'NodeAffinity',
nodeLost: 'NodeLost',
shutdown: 'Shutdown',
unexpectedAdmissionError: 'UnexpectedAdmissionError',
};
export function getPodStatusItems(
counts: InframonitoringtypesPodCountsByStatusDTO,
): StatusCountItem[] {
const errorKeys = Object.keys(ERROR_STATUS_LABELS) as ErrorStatusKey[];
const errorTotal = errorKeys.reduce((sum, key) => sum + counts[key], 0);
const errorBreakdown = errorKeys.map((key) => ({
label: ERROR_STATUS_LABELS[key],
value: counts[key],
}));
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{ value: counts.pending, label: 'Pending', color: Color.BG_AMBER_500 },
{ value: counts.succeeded, label: 'Succeeded', color: Color.BG_ROBIN_500 },
{ value: counts.failed, label: 'Failed', color: Color.BG_CHERRY_500 },
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorTotal,
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
},
];
}

View File

@@ -52,3 +52,7 @@
.divider {
--divider-color: rgba(255, 255, 255, 0.14);
}
.value {
width: fit-content;
}

View File

@@ -1,8 +1,9 @@
import { useCallback, type ReactNode, type MouseEvent } from 'react';
import { useCallback, type MouseEvent } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { toast } from '@signozhq/ui/sonner';
import { Copy, Minus, Plus } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import TanStackTable from 'components/TanStackTableView';
import { useInfraMonitoringCellActionsStore } from './useInfraMonitoringCellActionsStore';
@@ -11,12 +12,10 @@ import { Divider } from '@signozhq/ui/divider';
export interface CellValueTooltipProps {
value: string;
children: ReactNode;
}
export function CellValueTooltip({
value,
children,
}: CellValueTooltipProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { lineClamp, increaseLineClamp, decreaseLineClamp } =
@@ -94,7 +93,7 @@ export function CellValueTooltip({
className: styles.tooltipContentWrapper,
}}
>
{children}
<TanStackTable.Text className={styles.value}>{value}</TanStackTable.Text>
</TooltipSimple>
);
}

View File

@@ -17,9 +17,40 @@
flex-shrink: 0;
}
.valueWrapper {
min-width: 4ch;
}
.valueWrapperTooltip {
display: block;
width: fit-content;
}
.value {
font-variant-numeric: tabular-nums;
min-width: 4ch;
text-align: left;
cursor: default;
min-width: min-content;
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
}
.tooltipHeader {
font-weight: 600;
margin-bottom: 2px;
}
.tooltipRow {
display: flex;
justify-content: space-between;
gap: 12px;
}
.tooltipValue {
font-variant-numeric: tabular-nums;
}

View File

@@ -2,11 +2,18 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './GroupedStatusCounts.module.scss';
import TanStackTable from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
export interface StatusBreakdownItem {
label: string;
value: number;
}
export interface StatusCountItem {
value: number;
label: string;
color: string;
breakdown?: StatusBreakdownItem[];
}
interface GroupedStatusCountsProps {
@@ -14,6 +21,45 @@ interface GroupedStatusCountsProps {
showZeroValues?: boolean;
}
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
if (!item.breakdown || item.breakdown.length === 0) {
return (
<Typography.Text>
{item.label}: {item.value}
</Typography.Text>
);
}
const nonZeroBreakdown = item.breakdown.filter((b) => b.value > 0);
if (nonZeroBreakdown.length === 0) {
return (
<div className={styles.tooltipContent}>
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
<Typography.Text>No errors</Typography.Text>
</div>
);
}
return (
<div className={styles.tooltipContent}>
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
{nonZeroBreakdown.map((b) => (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
))}
</div>
);
}
export function GroupedStatusCounts({
items,
showZeroValues = true,
@@ -33,13 +79,15 @@ export function GroupedStatusCounts({
className={styles.separator}
style={{ backgroundColor: item.color }}
/>
<TooltipSimple title={`${item.label}: ${item.value}`}>
<span>
<TanStackTable.Text className={styles.value}>
{item.value || '-'}
</TanStackTable.Text>
</span>
</TooltipSimple>
<div className={styles.valueWrapper}>
<TooltipSimple title={buildTooltipContent(item)} arrow align="start">
<span className={styles.valueWrapperTooltip}>
<TanStackTable.Text className={styles.value}>
{item.value || '-'}
</TanStackTable.Text>
</span>
</TooltipSimple>
</div>
</div>
))}
</div>

View File

@@ -2,8 +2,12 @@ import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
// TODO(backend): Find a way to generate this via openapi
export const INFRA_MONITORING_ATTR_KEYS = {
@@ -130,6 +134,7 @@ export enum VIEWS {
CONTAINERS = 'containers',
PROCESSES = 'processes',
EVENTS = 'events',
POD_METRICS = 'pod_metrics',
}
export const VIEW_TYPES = {
@@ -137,6 +142,7 @@ export const VIEW_TYPES = {
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
EVENTS: VIEWS.EVENTS,
POD_METRICS: VIEWS.POD_METRICS,
};
export const K8sCategories = {
@@ -916,3 +922,261 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
[InfraMonitoringEntity.JOBS]: 'k8s.',
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
};
export interface WorkloadFilterContext {
workloadNameKey: string;
workloadNameValue: string;
clusterName: string;
namespaceName?: string;
}
export const podUtilizationByPodWidgetInfo = [
{
title: 'CPU Limit Utilization By Pod Name',
yAxisUnit: 'percentunit',
},
{
title: 'CPU Request Utilization By Pod Name',
yAxisUnit: 'percentunit',
},
{
title: 'Memory Limit Utilization By Pod Name',
yAxisUnit: 'percentunit',
},
{
title: 'Memory Request Utilization By Pod Name',
yAxisUnit: 'percentunit',
},
{
title: 'FileSystem Usage Percentage By Pod Name',
yAxisUnit: 'percentunit',
},
];
export function getPodUtilizationByPodQueryPayloads(
context: WorkloadFilterContext,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] {
const getKey = (dotKey: string, underscoreKey: string): string =>
dotMetricsEnabled ? dotKey : underscoreKey;
const k8sPodCpuLimitUtilKey = getKey(
'k8s.pod.cpu_limit_utilization',
'k8s_pod_cpu_limit_utilization',
);
const k8sPodCpuRequestUtilKey = getKey(
'k8s.pod.cpu_request_utilization',
'k8s_pod_cpu_request_utilization',
);
const k8sPodMemLimitUtilKey = getKey(
'k8s.pod.memory_limit_utilization',
'k8s_pod_memory_limit_utilization',
);
const k8sPodMemRequestUtilKey = getKey(
'k8s.pod.memory_request_utilization',
'k8s_pod_memory_request_utilization',
);
const k8sPodFsUsageKey = getKey(
'k8s.pod.filesystem.usage',
'k8s_pod_filesystem_usage',
);
const k8sPodFsCapacityKey = getKey(
'k8s.pod.filesystem.capacity',
'k8s_pod_filesystem_capacity',
);
const k8sPodNameKey = getKey('k8s.pod.name', 'k8s_pod_name');
const k8sClusterNameKey = getKey('k8s.cluster.name', 'k8s_cluster_name');
const k8sNamespaceNameKey = getKey('k8s.namespace.name', 'k8s_namespace_name');
const baseFilters = [
{
id: 'workload',
key: {
dataType: DataTypes.String,
id: `${context.workloadNameKey}--string--tag--false`,
key: context.workloadNameKey,
type: 'tag',
},
op: '=',
value: context.workloadNameValue,
},
{
id: 'cluster',
key: {
dataType: DataTypes.String,
id: `${k8sClusterNameKey}--string--tag--false`,
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: context.clusterName,
},
...(context.namespaceName
? [
{
id: 'namespace',
key: {
dataType: DataTypes.String,
id: `${k8sNamespaceNameKey}--string--tag--false`,
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: context.namespaceName,
},
]
: []),
];
const podNameGroupBy = [
{
dataType: DataTypes.String,
id: `${k8sPodNameKey}--string--tag--false`,
key: k8sPodNameKey,
type: 'tag',
},
];
const buildSingleMetricQuery = (
metricKey: string,
metricId: string,
): GetQueryResultsProps => ({
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: metricId,
key: metricKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [...baseFilters],
op: 'AND',
},
functions: [],
groupBy: podNameGroupBy,
having: [],
legend: `{{${k8sPodNameKey}}}`,
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: v4(),
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
});
const filesystemUsagePercentQuery: GetQueryResultsProps = {
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'fs_usage',
key: k8sPodFsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'A',
filters: {
items: [...baseFilters],
op: 'AND',
},
functions: [],
groupBy: podNameGroupBy,
having: [],
legend: `{{${k8sPodNameKey}}}`,
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'fs_capacity',
key: k8sPodFsCapacityKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'B',
filters: {
items: [...baseFilters],
op: 'AND',
},
functions: [],
groupBy: podNameGroupBy,
having: [],
legend: `{{${k8sPodNameKey}}}`,
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
legend: `{{${k8sPodNameKey}}}`,
queryName: 'F1',
},
],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: v4(),
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
};
return [
buildSingleMetricQuery(k8sPodCpuLimitUtilKey, 'cpu_limit_util'),
buildSingleMetricQuery(k8sPodCpuRequestUtilKey, 'cpu_request_util'),
buildSingleMetricQuery(k8sPodMemLimitUtilKey, 'mem_limit_util'),
buildSingleMetricQuery(k8sPodMemRequestUtilKey, 'mem_request_util'),
filesystemUsagePercentQuery,
];
}

View File

@@ -33,6 +33,8 @@ import {
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -43,7 +45,6 @@ import { isEmpty, isUndefined } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -51,7 +52,6 @@ import { Filter } from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 } from 'uuid';
import LogsActionsContainer from './LogsActionsContainer';
@@ -75,6 +75,7 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -262,7 +263,7 @@ function LogsExplorerViewsContainer({
}, [data?.payload]);
const handleExport = useCallback(
(dashboard: Dashboard | null, isNewDashboard?: boolean): void => {
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !selectedPanelType) {
return;
}
@@ -282,19 +283,26 @@ function LogsExplorerViewsContainer({
logEvent('Logs Explorer: Add to dashboard successful', {
panelType: selectedPanelType,
isNewDashboard,
dashboardName: dashboard?.data?.title,
dashboardName: dashboard?.title,
});
const dashboardEditView = generateExportToDashboardLink({
const dashboardEditView = getExportToDashboardLink({
query: exportDefaultQuery,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
safeNavigate(dashboardEditView);
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[safeNavigate, exportDefaultQuery, selectedPanelType],
[
safeNavigate,
exportDefaultQuery,
selectedPanelType,
getExportToDashboardLink,
],
);
useEffect(() => {

View File

@@ -13,12 +13,12 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
@@ -81,7 +81,7 @@ function Explorer(): JSX.Element {
const handleExport = useCallback(
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
_isNewDashboard?: boolean,
queryToExport?: Query,
): void => {

View File

@@ -14,6 +14,8 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
@@ -31,11 +33,9 @@ import {
} from 'pages/MetricsExplorer/aiActions';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import { v4 as uuid } from 'uuid';
@@ -63,6 +63,7 @@ function Explorer(): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
@@ -260,7 +261,7 @@ function Explorer(): JSX.Element {
const handleExport = useCallback(
(
dashboard: Dashboard | null,
dashboard: ExportDashboard | null,
_isNewDashboard?: boolean,
queryToExport?: Query,
): void => {
@@ -278,16 +279,18 @@ function Explorer(): JSX.Element {
};
}
const dashboardEditView = generateExportToDashboardLink({
const dashboardEditView = getExportToDashboardLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: dashboard.id,
widgetId,
});
safeNavigate(dashboardEditView);
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[exportDefaultQuery, safeNavigate, yAxisUnit],
[exportDefaultQuery, safeNavigate, yAxisUnit, getExportToDashboardLink],
);
const splitedQueries = useMemo(

View File

@@ -0,0 +1,93 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useCreateExportDashboard } from '../useCreateExportDashboard';
jest.mock('hooks/useIsDashboardV2');
jest.mock('api/v1/dashboards/create');
jest.mock('api/generated/services/dashboard', () => ({
createDashboardV2: jest.fn(),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: jest.fn(),
}),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockCreateV1 = createDashboardV1 as jest.Mock;
const mockCreateV2 = createDashboardV2 as jest.Mock;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const TITLE = 'My dashboard';
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCreateExportDashboard', () => {
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
mockUseIsDashboardV2.mockReturnValue(false);
const v1Dashboard = {
id: 'v1-new',
data: { title: TITLE },
} as unknown as Dashboard;
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith({ id: 'v1-new', title: TITLE }),
);
expect(mockCreateV1).toHaveBeenCalledWith({
title: TITLE,
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
expect(mockCreateV2).not.toHaveBeenCalled();
});
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
mockUseIsDashboardV2.mockReturnValue(true);
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith({ id: 'v2-new', title: TITLE }),
);
expect(mockCreateV2).toHaveBeenCalledWith(
expect.objectContaining({
schemaVersion: 'v6',
spec: expect.objectContaining({ display: { name: TITLE } }),
}),
);
expect(mockCreateV1).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
import { renderHook } from '@testing-library/react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useExportDashboards } from '../useExportDashboards';
jest.mock('hooks/useIsDashboardV2');
jest.mock('hooks/dashboard/useGetAllDashboard');
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: jest.fn(),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
const v1Refetch = jest.fn();
const v2Refetch = jest.fn();
const v1Dashboard = {
id: 'v1-1',
data: { title: 'V1 Dashboard' },
} as unknown as Dashboard;
const v1Other = {
id: 'v1-2',
data: { title: 'Other board' },
} as unknown as Dashboard;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAllDashboard.mockReturnValue({
data: { data: [v1Dashboard, v1Other] },
isLoading: false,
isFetching: false,
refetch: v1Refetch,
});
mockUseListV2.mockReturnValue({
data: {
data: {
dashboards: [
{
id: 'v2-1',
name: 'V2 Dashboard',
spec: { display: { name: 'V2 Dashboard' } },
},
],
},
},
isLoading: false,
isFetching: false,
refetch: v2Refetch,
});
});
describe('useExportDashboards', () => {
it('returns the V1 list and disables the V2 query when the flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
{ id: 'v1-2', title: 'Other board' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: { enabled: false, keepPreviousData: true },
}),
);
});
it('filters the V1 list in memory by title (case-insensitive)', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards('v1 dash'));
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
]);
});
it('returns the V2 list normalized to the export shape when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v2-1', title: 'V2 Dashboard' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: undefined }),
expect.objectContaining({
query: { enabled: true, keepPreviousData: true },
}),
);
});
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
mockUseIsDashboardV2.mockReturnValue(true);
renderHook(() => useExportDashboards('payments'));
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: "name CONTAINS 'payments'" }),
expect.anything(),
);
});
it('refetches the active source', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
result.current.refetch();
expect(v2Refetch).toHaveBeenCalledTimes(1);
expect(v1Refetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,45 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
jest.mock('hooks/useIsDashboardV2');
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const params = {
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
widgetId: 'w1',
};
describe('useGetExportToDashboardLink', () => {
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link?.startsWith('/dashboard/dash-1/new?')).toBe(true);
expect(link).toContain('graphType=');
expect(link).toContain('widgetId=w1');
expect(link).toContain('compositeQuery=');
});
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link?.startsWith('/dashboard/dash-1/panel/new?')).toBe(true);
expect(link).toContain('panelKind=signoz%2FTimeSeriesPanel');
expect(link).not.toContain('widgetId');
expect(link).toContain('compositeQuery=');
});
});

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import type { ExportDashboard } from './useExportDashboards';
interface UseCreateExportDashboardParams {
title: string;
onCreated: (dashboard: ExportDashboard) => void;
}
interface UseCreateExportDashboardResult {
create: () => void;
isLoading: boolean;
}
/**
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
* `createDashboardV2`; V1 uses the legacy create. Both normalize to an `ExportDashboard`.
*/
export function useCreateExportDashboard({
title,
onCreated,
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
const isDashboardV2 = useIsDashboardV2();
const { showErrorModal } = useErrorModal();
const onError = useCallback(
(error: unknown): void => showErrorModal(error as APIError),
[showErrorModal],
);
const v1 = useMutation(createDashboardV1, {
onSuccess: (data) => {
if (data.data) {
onCreated({ id: data.data.id, title: data.data.data.title ?? '' });
}
},
onError,
});
const v2 = useMutation(
() =>
createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
}),
{
onSuccess: (created) => {
onCreated({ id: created.data.id, title });
},
onError,
},
);
const create = useCallback((): void => {
if (isDashboardV2) {
v2.mutate();
} else {
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
}
}, [isDashboardV2, v1, v2, title]);
return {
create,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
};
}

View File

@@ -0,0 +1,90 @@
import { useCallback, useMemo } from 'react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useDebounce from 'hooks/useDebounce';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
const V2_LIST_LIMIT = 1000;
const SEARCH_DEBOUNCE_MS = 300;
/** Neutral id+title the picker uses in place of the V1/V2 dashboard entity. */
export interface ExportDashboard {
id: string;
title: string;
}
export interface UseExportDashboardsResult {
dashboards: ExportDashboard[];
/** First load only — disables the picker until there are options. */
isLoading: boolean;
/** Any fetch incl. a search refetch — drives the picker spinner. */
isFetching: boolean;
refetch: () => void;
}
function fromV2(
item: DashboardtypesListedDashboardForUserV2DTO,
): ExportDashboard {
return { id: item.id, title: item.spec.display?.name || item.name };
}
function fromV1(dashboard: Dashboard): ExportDashboard {
return { id: dashboard.id, title: dashboard.data.title ?? '' };
}
function filterByTitle(
dashboards: ExportDashboard[],
search: string,
): ExportDashboard[] {
const term = search.trim().toLowerCase();
if (!term) {
return dashboards;
}
return dashboards.filter((dashboard) =>
dashboard.title.toLowerCase().includes(term),
);
}
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
// as a name-contains clause (single quotes escaped).
function toNameQuery(search: string): string | undefined {
const term = search.trim();
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
}
/** Flag-aware picker source: V2 searches server-side (debounced), V1 filters in memory. */
export function useExportDashboards(search = ''): UseExportDashboardsResult {
const isDashboardV2 = useIsDashboardV2();
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
const v2 = useListDashboardsForUserV2(
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
);
const dashboards = useMemo<ExportDashboard[]>(
() =>
isDashboardV2
? (v2.data?.data?.dashboards ?? []).map(fromV2)
: filterByTitle((v1.data?.data ?? []).map(fromV1), search),
[isDashboardV2, v1.data, v2.data, search],
);
const refetch = useCallback((): void => {
if (isDashboardV2) {
void v2.refetch();
} else {
void v1.refetch();
}
}, [isDashboardV2, v1, v2]);
return {
dashboards,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
refetch,
};
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
interface ExportToDashboardLinkParams {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
widgetId: string;
}
/**
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
* editor; V1 uses the legacy new-widget link. `null` (V2 only) when the panel type has no
* V2 kind, so callers skip navigation.
*/
export function useGetExportToDashboardLink(): (
params: ExportToDashboardLinkParams,
) => string | null {
const isDashboardV2 = useIsDashboardV2();
return useCallback(
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
isDashboardV2
? buildExportPanelLink({ dashboardId, panelType, query })
: generateExportToDashboardLink({
query,
panelType,
dashboardId,
widgetId,
}),
[isDashboardV2],
);
}

View File

@@ -3,6 +3,7 @@ import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
containsAnyVariableReference,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
@@ -448,3 +449,25 @@ 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 { isArray } from 'lodash-es';
import { escapeRegExp, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -33,6 +33,23 @@ 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:
@@ -134,3 +151,30 @@ 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`,
);
}

View File

@@ -0,0 +1,86 @@
.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

@@ -0,0 +1,159 @@
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={cx(styles.textArea, !row.included && styles.disabled)}
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,98 @@
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

@@ -8,16 +8,17 @@ import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from './applyVariableToPanelsPatch';
import { useSaveVariables } from './useSaveVariables';
import { useVariableListActions } from './useVariableListActions';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
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';
@@ -55,11 +56,28 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
const {
confirmDeleteIndex,
setConfirmDeleteIndex,
impact,
setImpact,
handleFormSave,
handleMove,
requestDelete,
handleConfirmDelete,
handleImpactConfirm,
} = useVariableListActions({
dashboard,
variables,
setVariables,
isEditing,
setIsEditing,
save,
patchAsync,
});
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
return null;
@@ -95,60 +113,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
);
}, [editingFormModel, dashboard.spec.panels]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(
dashboard.spec.panels,
formModel.dynamicAttribute,
formModel.name,
selectedPanelIds,
);
if (ops.length === 0) {
return;
}
try {
await patchAsync(ops);
} catch {
toast.error('Could not update panels');
}
})();
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
@@ -202,7 +166,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onRequestDelete={requestDelete}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
@@ -220,6 +184,16 @@ 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

@@ -0,0 +1,250 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useState,
} from 'react';
import { toast } from '@signozhq/ui/sonner';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { buildSyncVariableToPanelsPatch } from './applyVariableToPanelsPatch';
import type { VariableFormModel } from './variableFormModel';
import {
applyVariableQueryEdits,
buildVariableImpactPatch,
} from './variableImpactPatch';
import {
findVariableUsages,
type VariableImpactMode,
type VariableUsage,
} from './variableUsages';
import type { EditingState } from './types';
/**
* 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.
*/
export interface VariableImpact {
mode: VariableImpactMode;
variableName: string;
newName?: string;
usages: VariableUsage[];
nextVariables: VariableFormModel[];
}
interface UseVariableListActionsParams {
dashboard: DashboardtypesGettableDashboardV2DTO;
variables: VariableFormModel[];
setVariables: Dispatch<SetStateAction<VariableFormModel[]>>;
isEditing: EditingState;
setIsEditing: Dispatch<SetStateAction<EditingState>>;
save: (variables: VariableFormModel[]) => Promise<boolean>;
patchAsync: (ops: DashboardtypesJSONPatchOperationDTO[]) => Promise<unknown>;
}
interface UseVariableListActions {
confirmDeleteIndex: number | null;
setConfirmDeleteIndex: Dispatch<SetStateAction<number | null>>;
impact: VariableImpact | null;
setImpact: Dispatch<SetStateAction<VariableImpact | null>>;
handleFormSave: (
formModel: VariableFormModel,
selectedPanelIds: string[],
) => void;
handleMove: (from: number, to: number) => void;
requestDelete: (index: number) => void;
handleConfirmDelete: (index: number) => void;
handleImpactConfirm: (resolvedUsages: VariableUsage[]) => Promise<void>;
}
/**
* Variables-list mutation and impact-flow actions: reorder, save/rename, and the
* referenced-variable delete/rename flows resolved through the impact dialog.
* Owns the delete-confirm and pending-impact state the list renders against.
*/
export function useVariableListActions({
dashboard,
variables,
setVariables,
isEditing,
setIsEditing,
save,
patchAsync,
}: UseVariableListActionsParams): UseVariableListActions {
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const [impact, setImpact] = useState<VariableImpact | null>(null);
const persist = useCallback(
(next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
},
[save, setVariables],
);
const handleFormSave = useCallback(
(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;
}
// 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> => {
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(
dashboard.spec.panels,
formModel.dynamicAttribute,
formModel.name,
selectedPanelIds,
);
if (ops.length === 0) {
return;
}
try {
await patchAsync(ops);
} catch {
toast.error('Could not update panels');
}
})();
},
[
dashboard,
isEditing,
patchAsync,
save,
setIsEditing,
setVariables,
variables,
],
);
const handleMove = useCallback(
(from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
},
[persist, variables],
);
const handleConfirmDelete = useCallback(
(index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
},
[persist, variables],
);
// 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 = useCallback(
(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);
},
[dashboard, variables],
);
// 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 = useCallback(
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);
},
[dashboard, impact, patchAsync, setVariables],
);
return {
confirmDeleteIndex,
setConfirmDeleteIndex,
impact,
setImpact,
handleFormSave,
handleMove,
requestDelete,
handleConfirmDelete,
handleImpactConfirm,
};
}

View File

@@ -0,0 +1,123 @@
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

@@ -0,0 +1,163 @@
import type {
DashboardtypesGettableDashboardV2DTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { removeVariableFromExpression } from 'components/QueryBuilderV2/utils';
import {
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'
? removeVariableFromExpression(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

@@ -2,7 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isQueryTypeSupported,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
@@ -36,7 +36,7 @@ export function getPanelTypeDisabledReason({
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
if (!isQueryTypeSupported(kind, queryType)) {
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {

View File

@@ -1,4 +1,8 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
buildExportPanelLink,
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
@@ -31,4 +35,57 @@ describe('newPanelRoute', () => {
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
).toBeNull();
});
describe('buildExportPanelLink', () => {
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const parseLink = (
link: string | null,
): { path: string; params: URLSearchParams } => {
if (link === null) {
throw new Error('expected a link, got null');
}
const [path, search] = link.split('?');
return { path, params: new URLSearchParams(search) };
};
it.each([
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
[PANEL_TYPES.LIST, 'signoz/ListPanel'],
])('maps export panel type %s to kind %s', (panelType, expectedKind) => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType,
query,
});
const { path, params } = parseLink(link);
expect(path).toBe('/dashboard/dash-1/panel/new');
expect(params.get('panelKind')).toBe(expectedKind);
expect(parseNewPanelKind(NEW_PANEL_ID, `?${params.toString()}`)).toBe(
expectedKind,
);
});
it('carries the query as a decodable compositeQuery param', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
});
const { params } = parseLink(link);
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
query,
);
});
it('returns null for a panel type with no V2 kind', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.EMPTY_WIDGET,
query,
});
expect(link).toBeNull();
});
});
});

View File

@@ -83,7 +83,6 @@ function PanelEditorContainer({
setSpec,
isSpecDirty,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
@@ -169,10 +168,11 @@ function PanelEditorContainer({
onChangeSpec: setSpec,
});
// Seed a new List panel's default columns so the Columns control isn't empty.
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: defaultSignal,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});

View File

@@ -1,11 +1,17 @@
import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
PANEL_KIND_TO_PANEL_TYPE,
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels share a fixed id segment, carrying kind + target section
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
// id is generated on save.
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
// query (`/panel/new?panelKind=&layoutIndex=…`); the real id is generated on save.
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
@@ -37,6 +43,34 @@ export function parseNewPanelKind(
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
}
/**
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
* raw `Query` as `compositeQuery` encoded as the V1 link so `useGetCompositeQueryParam`
* reads it identically (conversion happens in the editor). `null` when the panel type has
* no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
*/
export function buildExportPanelLink({
dashboardId,
panelType,
query,
}: {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
}): string | null {
const kind = PANEL_TYPE_TO_PANEL_KIND[panelType];
if (!kind) {
return null;
}
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(query))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);

View File

@@ -7,7 +7,7 @@ import {
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupported,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
resolveQueryType,
} from '../capabilities';
@@ -48,14 +48,24 @@ describe('panel capabilities guard', () => {
});
it('Table and Pie do not support PromQL', () => {
expect(isQueryTypeSupported('signoz/TablePanel', PROM)).toBe(false);
expect(isQueryTypeSupported('signoz/PieChartPanel', PROM)).toBe(false);
expect(isQueryTypeSupportedByPanelKind('signoz/TablePanel', PROM)).toBe(
false,
);
expect(isQueryTypeSupportedByPanelKind('signoz/PieChartPanel', PROM)).toBe(
false,
);
});
it('List only supports Query Builder', () => {
expect(isQueryTypeSupported('signoz/ListPanel', QUERY_BUILDER)).toBe(true);
expect(isQueryTypeSupported('signoz/ListPanel', CLICKHOUSE)).toBe(false);
expect(isQueryTypeSupported('signoz/ListPanel', PROM)).toBe(false);
expect(
isQueryTypeSupportedByPanelKind('signoz/ListPanel', QUERY_BUILDER),
).toBe(true);
expect(isQueryTypeSupportedByPanelKind('signoz/ListPanel', CLICKHOUSE)).toBe(
false,
);
expect(isQueryTypeSupportedByPanelKind('signoz/ListPanel', PROM)).toBe(
false,
);
});
});

View File

@@ -32,7 +32,7 @@ export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getPanelDefinition(kind).supportedQueryTypes;
}
export function isQueryTypeSupported(
export function isQueryTypeSupportedByPanelKind(
kind: PanelKind,
queryType: EQueryType,
): boolean {
@@ -53,7 +53,7 @@ export function isPanelCombinationValid({
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
if (!isQueryTypeSupported(kind, queryType)) {
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return false;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {

View File

@@ -19,7 +19,6 @@ let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
{};
const mockSetVariableValue = jest.fn();
const mockSetUrlValues = jest.fn();
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
const DYNAMIC_KIND = 'signoz/DynamicVariable';
@@ -67,16 +66,6 @@ jest.mock(
DYNAMIC_SIGNAL_ALL: 'all',
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState',
() => ({
ALL_SELECTED: '__ALL__',
variablesUrlParser: { withOptions: (): unknown => ({}) },
}),
);
jest.mock('nuqs', () => ({
useQueryState: (): unknown => [null, mockSetUrlValues],
}));
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useQueryState } from 'nuqs';
import {
dtoToFormModel,
formModelToDto,
@@ -18,10 +17,6 @@ import { useOptimisticPatch } from 'pages/DashboardPageV2/DashboardContainer/hoo
import { selectVariableValues } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/variablesUrlState';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
@@ -57,7 +52,7 @@ export interface UseDrilldownDashboardVariablesApi {
/**
* "Dashboard Variables" submenu logic (V1 `useDashboardVarConfig` parity). Set/Unset are runtime-only
* (store + URL — V2 selections don't persist); Create is the one path that patches `spec.variables`.
* (store — V2 selections aren't in the spec); Create is the one path that patches `spec.variables`.
*/
export function useDrilldownDashboardVariables({
filters,
@@ -78,10 +73,6 @@ export function useDrilldownDashboardVariables({
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
const [, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
const { patchAsync } = useOptimisticPatch();
const fieldVariables = useMemo<[string, string | number][]>(
@@ -94,16 +85,12 @@ export function useDrilldownDashboardVariables({
[filters],
);
// Runtime-only write (store + URL), never the spec — mirrors VariablesBar's setSelection.
// Runtime-only store write, never the spec — mirrors VariablesBar's setSelection.
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
[dashboardId, setVariableValue],
);
const handleCreate = useCallback(

View File

@@ -1,3 +1,4 @@
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
@@ -5,11 +6,12 @@ import { Tooltip } from 'antd';
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 { computeVariableDependencies } from './variableDependencies';
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;
@@ -32,50 +34,48 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
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}
/>
);
}
};
// Dependency links shown in the hover tooltip: variables this one's query
// references (dependsOn = its parents) and query variables that reference this
// one (usedBy = its children), from the shared dependency graph.
const { dependsOn, usedBy } = useMemo(() => {
const { graph, parentGraph } = computeVariableDependencies(variables);
return {
dependsOn: parentGraph[variable.name] ?? [],
usedBy: graph[variable.name] ?? [],
};
}, [variable.name, 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}
/>
);
return (
<div
@@ -84,14 +84,29 @@ function VariableSelector({
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
{hasTooltip ? (
<Tooltip
title={
<VariableTooltip
description={variable.description}
dependsOn={dependsOn}
usedBy={usedBy}
/>
}
>
<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

@@ -0,0 +1,67 @@
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,10 +73,57 @@
}
.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;
@@ -87,7 +134,7 @@
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 12px;
font-weight: 400;
@@ -97,11 +144,63 @@
.infoIcon {
display: inline-flex;
margin-left: 2px;
margin-left: 6px;
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

@@ -0,0 +1,153 @@
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

@@ -116,13 +116,17 @@ describe('useSeedVariableSelection', () => {
});
});
it('prunes URL entries for variables that no longer exist', () => {
it('seeds from the URL then clears it (read-once share link)', () => {
mockUrlValues = { env: 'prod', removed: 'stale' };
const dash = dashboard('d1', [model({ name: 'env', type: 'TEXT' })]);
renderHook(() => useSeedVariableSelection(dash));
expect(mockSetUrlValues).toHaveBeenCalledWith({ env: 'prod' });
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
expect(mockSetUrlValues).toHaveBeenCalledWith(null);
});
it('writes nothing while the dashboard is still loading', () => {

View File

@@ -1,31 +0,0 @@
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

@@ -0,0 +1,190 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
import { ALL_SELECTED } from './variablesUrlState';
// One default resolver, shared by the seed, the post-fetch reconcile and the
// payload fallback, so the bar, the fetch gate and the query payload can never
// disagree about a variable's default.
const ALL_SELECTION: VariableSelection = { value: null, allSelected: true };
/**
* A configured `defaultValue` reduced to its non-empty form (array as-is, string
* as-is), or undefined when unset — the one place the raw shapes are normalized.
*/
function configuredDefault(
defaultValue: VariableFormModel['defaultValue'],
): Exclude<SelectedVariableValue, null> | undefined {
if (Array.isArray(defaultValue)) {
return defaultValue.length > 0 ? defaultValue : undefined;
}
return defaultValue || undefined;
}
/**
* The configured default as a single value (first of an array). undefined means
* "no configured default", so callers fall through to the first option / ALL.
*/
function firstConfiguredDefault(model: VariableFormModel): string | undefined {
const value = configuredDefault(model.defaultValue);
if (value === undefined) {
return undefined;
}
return Array.isArray(value) ? String(value[0]) : String(value);
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
/** Whether the configured default marks the ALL sentinel. */
function isAllDefault(
defaultValue: VariableFormModel['defaultValue'],
): boolean {
return (
defaultValue === ALL_SELECTED ||
(Array.isArray(defaultValue) &&
defaultValue.length === 1 &&
defaultValue[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: textDefault(model), allSelected: false };
}
if (isAllDefault(model.defaultValue)) {
return ALL_SELECTION;
}
const configured = configuredDefaultValue(model);
if (configured !== undefined) {
return {
value:
Array.isArray(configured) || !model.multiSelect ? configured : [configured],
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,
): Exclude<SelectedVariableValue, null> | undefined {
if (model.type === 'TEXT') {
return textDefault(model);
}
return configuredDefault(model.defaultValue);
}

View File

@@ -1,3 +1,4 @@
import type { VariableType } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
@@ -19,6 +20,33 @@ 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}, an ALL selection counts only
* once it holds the concrete option array — for QUERY that's after its fetch, for
* CUSTOM it's materialized synchronously (no fetch). 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

@@ -1,50 +0,0 @@
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

@@ -1,140 +0,0 @@
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

@@ -1,127 +0,0 @@
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

@@ -59,7 +59,8 @@ function ValueSelector({
placeholder="Select value"
maxTagCount={2}
maxTagTextLength={20}
enableAllSelection={showAllOption}
// Offer ALL only once options load, else a concrete value reads as "all".
enableAllSelection={showAllOption && options.length > 0}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)

View File

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,196 @@
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 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;
}
/**
* Options + loading/error state for a FETCHED list variable (QUERY / DYNAMIC),
* 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/TEXT never fetch here (the queries stay disabled).
*/
export function useFetchedVariableOptions(
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]);
// 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 === '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

@@ -0,0 +1,43 @@
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 { VariableSelectionMap } from '../selectionTypes';
import {
useFetchedVariableOptions,
type VariableOptions,
} from './useFetchedVariableOptions';
export type { VariableOptions };
/**
* 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 options come from {@link useFetchedVariableOptions} (fetch engine).
* 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 fetched = useFetchedVariableOptions(variable, variables, selections);
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);
if (variable.type === 'CUSTOM') {
return { options: customOptions, loading: false, errorMessage: null };
}
return fetched;
}

View File

@@ -1,61 +1,14 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
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))
);
}
import { reconcileWithOptions } from './resolveVariableSelection';
import type { VariableSelection } from './selectionTypes';
/**
* 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.
* 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.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -64,36 +17,10 @@ export function useAutoSelect(
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0) {
return;
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
}
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,6 +6,7 @@ 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,
@@ -17,26 +18,6 @@ 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(
@@ -88,36 +69,37 @@ export function useSeedVariableSelection(
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (selection[variable.name]) {
seeded[variable.name] = selection[variable.name];
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;
} else {
seeded[variable.name] = defaultSelection(variable);
seeded[variable.name] = resolveDefaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// Drop URL selections for variables that no longer exist (renamed/removed),
// so a shared link doesn't carry stale entries a later variable could inherit.
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never
// write it back (only an explicit Share action re-materializes it).
if (urlValues) {
const validNames = new Set(variables.map((v) => v.name));
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
if (orphaned) {
const pruned: Record<string, SelectedVariableValue> = {};
Object.entries(urlValues).forEach(([name, value]) => {
if (validNames.has(name)) {
pruned[name] = value;
}
});
void setUrlValues(pruned);
}
void setUrlValues(null);
}
// 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 || variables.length === 0) {
if (!dashboardId) {
return;
}
const names = variables

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useQueryState } from 'nuqs';
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
import { useSelector } from 'react-redux';
import type { AppState } from 'store/reducers';
@@ -11,8 +10,12 @@ 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[];
@@ -29,8 +32,8 @@ interface UseVariableSelection {
/**
* Runtime variable selection for the variables bar: seeds values and the fetch
* context (via useSeedVariableSelection), runs the options fetch cycle, and
* persists changes to both the store and the URL. Never writes to the dashboard
* spec.
* persists changes to the store (the source of truth). Never writes to the URL
* or the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
@@ -48,45 +51,59 @@ export function useVariableSelection(
(s) => s.enqueueDescendantsBatch,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = 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).
const selectionRef = useRef(selection);
selectionRef.current = selection;
const [, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
// 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.
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;
return undefined;
}
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
fetchCycleTimer.current = setTimeout(
() => enqueueFetchAll(fetchCycleKey),
FETCH_CYCLE_DEBOUNCE_MS,
);
return (): void => {
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, orderKey, minTime, maxTime]);
}, [dashboardId, fetchCycleKey]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
[dashboardId, setVariableValue, enqueueDescendants],
);
// Coalesce the initial load burst of auto-selections: each selector fills its
@@ -105,16 +122,8 @@ export function useVariableSelection(
return;
}
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
void setUrlValues((prev) => {
const next = { ...(prev ?? {}) };
names.forEach((name) => {
const sel = fills[name];
next[name] = sel.allSelected ? ALL_SELECTED : sel.value;
});
return next;
});
enqueueDescendantsBatch(names);
}, [dashboardId, setVariableValues, setUrlValues, enqueueDescendantsBatch]);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(
(name: string, next: VariableSelection): void => {

View File

@@ -4,8 +4,6 @@ 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
@@ -242,17 +240,3 @@ 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,4 +1,3 @@
import { QueryParams } from 'constants/query';
import { parseAsJson } from 'nuqs';
import type { SelectedVariableValue } from './selectionTypes';
@@ -14,21 +13,3 @@ 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,12 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } 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;
@@ -26,7 +25,6 @@ interface UseCreatePanelResult {
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -50,11 +48,10 @@ export function useCreatePanel(): UseCreatePanelResult {
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
safeNavigate(
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
);
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex, search],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -1,23 +1,63 @@
import { isResolved } from '../VariablesBar/selectionUtils';
import { hasUsableValue } from '../VariablesBar/selectionUtils';
import { VariableFetchState } from '../store/slices/variableFetchSlice';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* 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`.
* 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).
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const states = useDashboardStore((s) => s.variableFetchStates);
const variableTypes = useDashboardStore(
(s) => s.variableFetchContext?.variableTypes,
);
const fetchStates = useDashboardStore((s) => s.variableFetchStates);
const resolvedEmpty = useDashboardStore((s) => s.variableResolvedEmpty);
const selection = useDashboardStore(selectVariableValues(dashboardId));
// Before the variable bar seeds the fetch context there are no types to gate on;
// usePanelQuery holds such panels via its own `!fetchContext` check.
if (!variableTypes) {
return false;
}
return names.some((name) => {
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
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];
});
}

View File

@@ -1,17 +1,16 @@
import { useCallback } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } 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. The `?variables=` selection is carried
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
* 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
* 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.
@@ -21,19 +20,15 @@ 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,
})}${withVariablesSearch('', search)}`,
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId, search],
[safeNavigate, dashboardId],
);
}

View File

@@ -19,7 +19,10 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import {
getReferencedVariables,
queryReferencesAnyVariable,
} 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';
@@ -57,9 +60,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) — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
/** 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`. */
isLoading: boolean;
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
/** 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. */
isFetching: boolean;
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
isPreviousData: boolean;
@@ -131,6 +134,13 @@ 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) => {
@@ -141,11 +151,11 @@ export function usePanelQuery({
return scoped;
}, [variables, referencedVariableNames]);
// First-load gate: hold the panel in its loading state until every referenced
// variable has resolved a value.
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
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);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -309,8 +319,10 @@ export function usePanelQuery({
return {
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
// 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,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,

View File

@@ -33,6 +33,11 @@ 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

@@ -0,0 +1,69 @@
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,21 +33,6 @@ 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
@@ -74,7 +59,7 @@ function resolveValue(
return selected as Querybuildertypesv5VariableItemDTOValue;
}
const fallback = configuredDefault(definition);
const fallback = configuredDefaultValue(definition);
return fallback == null
? undefined
: (fallback as Querybuildertypesv5VariableItemDTOValue);

View File

@@ -1,5 +1,8 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import {
containsAnyVariableReference,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
@@ -45,3 +48,18 @@ 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,14 +12,25 @@ 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: {},
@@ -47,28 +58,37 @@ describe('variableFetchSlice', () => {
});
});
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
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.
expect(states()).toMatchObject({
q1: 'loading',
q2: 'waiting',
d1: 'waiting',
d2: 'waiting',
d1: 'loading',
d2: 'loading',
});
});
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
store().enqueueFetchAll(true);
expect(states().d1).toBe('loading');
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('completing a parent unblocks its query child, then unlocks dynamics', () => {
store().enqueueFetchAll(false);
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();
store().onVariableFetchComplete('q1');
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
expect(states().q2).not.toBe('waiting');
});
it('ignores a settle for a variable that is not actively fetching', () => {
@@ -79,8 +99,14 @@ describe('variableFetchSlice', () => {
});
it('changing a query variable revalidates query descendants but NOT dynamics', () => {
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
// 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));
const before = { ...store().variableCycleIds };
store().enqueueDescendants('q1');
@@ -91,7 +117,7 @@ describe('variableFetchSlice', () => {
});
it('changing a dynamic refreshes the OTHER dynamics, never itself or query vars', () => {
store().enqueueFetchAll(true);
store().enqueueFetchAll();
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
@@ -102,13 +128,29 @@ describe('variableFetchSlice', () => {
});
it('a failed parent idles its query descendants', () => {
store().enqueueFetchAll(false);
store().enqueueFetchAll();
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' });
@@ -118,15 +160,19 @@ describe('variableFetchSlice — diamond dependencies', () => {
beforeEach(() => reset(['qA', 'qB', 'qC'], context));
it('unblocks the child only once BOTH parents are settled', () => {
store().enqueueFetchAll(false);
it('unblocks the child only once BOTH parents have committed values', () => {
store().enqueueFetchAll();
expect(states().qC).toBe('waiting');
store().onVariableFetchComplete('qA');
expect(states().qC).toBe('waiting'); // qB still loading
resolve('qA');
store().enqueueDescendants('qA');
expect(states().qC).toBe('waiting'); // qB has no value yet
store().onVariableFetchComplete('qB');
expect(states().qC).not.toBe('waiting'); // both settled → fetches
resolve('qB');
store().enqueueDescendants('qB');
expect(states().qC).not.toBe('waiting'); // both valued → fetches
});
});
@@ -139,7 +185,7 @@ describe('variableFetchSlice — dependency cycle', () => {
beforeEach(() => reset(['qX', 'qY'], context));
it('enqueues cyclic query variables as best-effort roots (not silently idle)', () => {
store().enqueueFetchAll(false);
store().enqueueFetchAll();
expect(states().qX).not.toBe('idle');
expect(states().qY).not.toBe('idle');
});

View File

@@ -1,23 +1,42 @@
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 together once query values exist,
* variables in dependency order, dynamics immediately (they are scoped only by
* sibling dynamic selections, never by query variables, so nothing gates them),
* 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.
@@ -26,13 +45,35 @@ 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;
/** Start a full fetch cycle for every fetchable variable (load / time change). */
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => 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;
/** 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. */
@@ -65,10 +106,32 @@ 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]) {
@@ -82,17 +145,23 @@ 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: (doAllQueryVariablesHaveValuesSelected): void => {
enqueueFetchAll: (key): void => {
// Skip a redundant re-run (re-mount with identical inputs) — else it doubles.
if (key && key === get().lastFetchAllKey) {
return;
}
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
@@ -105,7 +174,11 @@ export const createVariableFetchSlice: StateCreator<
} = variableFetchContext;
const maps = cloneMaps(get());
// Query variables: roots start immediately, dependents wait for parents.
// 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.
queryVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
@@ -116,8 +189,8 @@ export const createVariableFetchSlice: StateCreator<
});
// Query variables dropped from the dependency order (part of a cycle) would
// otherwise never fetch and would stall waiting dynamics — start them as
// best-effort roots so they surface data/an error instead of sitting empty.
// otherwise never fetch — 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)) {
@@ -126,19 +199,21 @@ export const createVariableFetchSlice: StateCreator<
}
});
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
// 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`.
dynamicVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(maps, name)
: VariableFetchState.Waiting;
maps.states[name] = resolveFetchState(maps, name);
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
lastFetchAllKey: key ?? get().lastFetchAllKey,
});
},
@@ -154,10 +229,11 @@ export const createVariableFetchSlice: StateCreator<
maps.lastUpdated[name] = Date.now();
if (variableFetchContext) {
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.
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.
(dependencyData.graph[name] || []).forEach((child) => {
if (
variableTypes[child] !== 'QUERY' ||
@@ -165,18 +241,10 @@ export const createVariableFetchSlice: StateCreator<
) {
return;
}
const parents = dependencyData.parentGraph[child] || [];
if (parents.every((p) => isSettled(maps.states[p]))) {
if (queryParentsHaveValues(child, variableFetchContext, selection)) {
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({
@@ -195,20 +263,14 @@ export const createVariableFetchSlice: StateCreator<
maps.states[name] = VariableFetchState.Error;
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Query descendants can't proceed without this parent — idle them.
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).
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
if (variableTypes[name] === 'QUERY' && variableTypes[desc] === 'QUERY') {
maps.states[desc] = VariableFetchState.Idle;
}
});
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
@@ -231,9 +293,11 @@ 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());
// Union of the changed variables' query descendants (never the changed ones
// themselves), refreshed once each: refetch when all parents are settled.
// 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.
const queryDescendants = new Set<string>();
names.forEach((name) => {
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
@@ -244,27 +308,24 @@ export const createVariableFetchSlice: StateCreator<
});
queryDescendants.forEach((desc) => {
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
const parents = dependencyData.parentGraph[desc] || [];
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
maps.states[desc] = allParentsSettled
maps.states[desc] = queryParentsHaveValues(
desc,
variableFetchContext,
selection,
)
? 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 (never the one
// that changed, which would refetch its own identical options).
// dynamic change affects them — refresh the *other* dynamics immediately
// (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] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: VariableFetchState.Waiting;
maps.states[dynName] = resolveFetchState(maps, dynName);
});
}

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