Compare commits

...

5 Commits

Author SHA1 Message Date
Ashwin Bhatkal
2b2f8bf35e feat(dashboard-v2): surface dashboard variables in query builder suggestions
Publish the dashboard's variables into the shared store the query builder autocomplete reads, so $variable suggestions appear in the dashboards-page builder and the panel editor.
2026-07-02 13:37:41 +05:30
Ashwin Bhatkal
21cce41efa feat(dashboard-v2): scope panel refetch to referenced variables
A panel now refetches only when a variable its queries reference changes (the cache key is scoped to referenced variables; the full payload is still substituted), and stays in its loading state on first load until those variables resolve.
2026-07-02 13:37:40 +05:30
Ashwin Bhatkal
114170b9cd feat(dashboard-v2): drive variable selectors off the fetch engine
Gate the Query/Dynamic option fetches on the engine's per-variable state and key them by cycle id (so a parent change refetches only its dependents, in order, with no duplicate calls), and report completion/failure back. Wire the selection lifecycle: full fetch cycle on load/time change, descendant cascade on value change.
2026-07-02 13:37:40 +05:30
Ashwin Bhatkal
b2eef28549 feat(dashboard-v2): add runtime variable fetch-state engine
Port V1's variable fetch orchestration natively onto the dashboard store: a fetch-state slice (idle/loading/revalidating/waiting/error + per-variable cycle ids) plus the dependency context derivation (query/dynamic order) needed to schedule fetches. Query variables load in dependency order; dynamics wait for query values. enqueueFetchAll drives first load/time change, enqueueDescendants drives a single value change.
2026-07-02 13:37:40 +05:30
Ashwin Bhatkal
71eabac1e7 fix(dashboards): stop query cache collisions on public dashboards (#11935)
The public payload redacts each widget's query (filters/limit/orderBy
stripped), so panels differing only by their filter arrive with identical
query bodies. The react-query key was built from that query body, so those
panels hashed to the same key and were deduped into one request — its data
filled every colliding panel while other indices were never fetched.

Key each panel on what determines its response — widget id + index + time —
instead of the redacted query body.

Fixes SigNoz/engineering-pod#5503
2026-07-02 06:07:21 +00:00
19 changed files with 890 additions and 57 deletions

View File

@@ -79,13 +79,11 @@ function Panel({
},
ENTITY_VERSION_V5,
{
queryKey: [
widget?.query,
widget?.panelTypes,
requestData,
startTime,
endTime,
],
// Public data is fetched by index and the payload redacts each widget's
// filters, so query bodies are identical across panels. Key on panel
// identity + time — the only inputs that determine the response — so
// panels don't collapse onto one cache entry.
queryKey: [widget?.id, index, startTime, endTime],
retry(failureCount, error): boolean {
if (
String(error).includes('status: error') &&

View File

@@ -0,0 +1,79 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render } from 'tests/test-utils';
import { Widgets } from 'types/api/dashboard/getAll';
import Panel from '../Panel';
const useGetQueryRangeMock = jest.fn();
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
useGetQueryRange: (...args: unknown[]): unknown => {
useGetQueryRangeMock(...args);
return {
data: undefined,
isFetching: false,
isLoading: false,
isSuccess: true,
isError: false,
};
},
}));
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="widget-graph" />,
}));
const buildWidget = (id: string): Widgets =>
({
id,
panelTypes: PANEL_TYPES.LIST,
query: {
builder: {
queryData: [{ dataSource: 'logs', limit: 100, orderBy: [] }],
},
},
timePreferance: 'GLOBAL_TIME',
}) as unknown as Widgets;
describe('Public dashboard Panel', () => {
beforeEach(() => {
useGetQueryRangeMock.mockClear();
});
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
render(
<>
<Panel
widget={buildWidget('widget-a')}
index={2}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>
<Panel
widget={buildWidget('widget-b')}
index={62}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>
</>,
);
const [callA, callB] = useGetQueryRangeMock.mock.calls;
const queryKeyA = callA[2].queryKey;
const metaA = callA[4];
const queryKeyB = callB[2].queryKey;
const metaB = callB[4];
// Key is panel identity + time only — the redacted query body is not part
// of it, so identical query bodies can't collapse two panels onto one key.
expect(queryKeyA).toStrictEqual(['widget-a', 2, 100, 200]);
expect(queryKeyB).toStrictEqual(['widget-b', 62, 100, 200]);
expect(queryKeyA).not.toStrictEqual(queryKeyB);
expect(metaA.widgetIndex).toBe(2);
expect(metaB.widgetIndex).toBe(62);
});
});

View File

@@ -18,8 +18,6 @@ interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
@@ -30,7 +28,6 @@ interface VariableSelectorProps {
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
@@ -61,7 +58,6 @@ function VariableSelector({
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}

View File

@@ -23,8 +23,7 @@ interface VariablesBarProps {
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
const { variables, selection, setSelection } = useVariableSelection(dashboard);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
@@ -57,7 +56,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -10,12 +11,14 @@ import {
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 {
@@ -30,7 +33,9 @@ interface DynamicSelectorProps {
/**
* 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`).
* (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,
@@ -48,14 +53,51 @@ function DynamicSelector({
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching } = 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,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
@@ -71,7 +113,7 @@ function DynamicSelector({
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
loading={isFetching || isVariableWaiting}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -8,18 +8,18 @@ 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 { isResolved, selectionToPayload } from '../selectionUtils';
import { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
@@ -27,14 +27,15 @@ interface QuerySelectorProps {
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
* 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,
parents,
selections,
selection,
onChange,
@@ -43,23 +44,43 @@ function QuerySelector({
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
@@ -79,7 +100,7 @@ function QuerySelector({
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
loading={isFetching || isVariableWaiting}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -0,0 +1,44 @@
import {
selectVariableCycleId,
selectVariableFetchedOnce,
selectVariableFetchState,
type VariableFetchState,
} from '../store/slices/variableFetchSlice';
import { useDashboardStore } from '../store/useDashboardStore';
export interface VariableFetchStateResult {
variableFetchState: VariableFetchState;
/** Include in the selector's react-query key to auto-cancel stale requests. */
variableFetchCycleId: number;
/** Actively fetching (first load or revalidating). */
isVariableFetching: boolean;
/** Stable — the fetch completed (or errored). */
isVariableSettled: boolean;
/** Blocked on parent dependencies (query order) or query variables (dynamics). */
isVariableWaiting: boolean;
/** Completed at least one fetch — keeps the query subscribed so a cycle bump refetches. */
hasVariableFetchedOnce: boolean;
}
/**
* Per-variable view of the runtime fetch engine (`variableFetchSlice`), consumed
* by the Query/Dynamic selectors to gate their fetch and key it by cycle id.
* V2-native equivalent of V1's `useVariableFetchState`.
*/
export function useVariableFetchState(name: string): VariableFetchStateResult {
const variableFetchState = useDashboardStore(selectVariableFetchState(name));
const variableFetchCycleId = useDashboardStore(selectVariableCycleId(name));
const hasVariableFetchedOnce = useDashboardStore(
selectVariableFetchedOnce(name),
);
return {
variableFetchState,
variableFetchCycleId,
isVariableFetching:
variableFetchState === 'loading' || variableFetchState === 'revalidating',
isVariableSettled: variableFetchState === 'idle',
isVariableWaiting: variableFetchState === 'waiting',
hasVariableFetchedOnce,
};
}

View File

@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
import { useSelector } from 'react-redux';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
@@ -12,8 +16,8 @@ import type {
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
deriveFetchContext,
doAllQueryVariablesHaveValues,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
@@ -45,7 +49,6 @@ function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
@@ -64,27 +67,34 @@ export function useVariableSelection(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Latest selection, read by the fetch-cycle effect without subscribing to it
// (so a value change doesn't re-trigger a full fetch cycle).
const selectionRef = useRef(selection);
selectionRef.current = selection;
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
// Seed selections: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
@@ -101,16 +111,37 @@ export function useVariableSelection(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
// Start a full fetch cycle on load / dependency-order / time change. Runs after
// the seeding effect above, so it reads the seeded selection from the store; a
// value change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables
.map((v) => v.name)
.filter((name): name is string => !!name);
initVariableFetch(names, fetchContext);
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, orderKey, minTime, maxTime]);
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, setUrlValues],
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
return { variables, selection, setSelection };
}

View File

@@ -1,6 +1,11 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
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
@@ -197,3 +202,57 @@ export function computeVariableDependencies(
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}
/**
* Static context the runtime fetch engine (`variableFetchSlice`) needs to order
* fetches: the dependency graph plus the per-name type index and the QUERY /
* DYNAMIC fetch orders. Derived from the variable definitions; stable until the
* spec's variables change. Mirrors V1's `getVariableDependencyContext`.
*/
export interface VariableFetchContext {
dependencyData: VariableDependencyData;
/** variable name → its type. */
variableTypes: Record<string, VariableType>;
/** QUERY variables in topological (parent-before-child) order. */
queryVariableOrder: string[];
/** DYNAMIC variable names (they implicitly depend on all QUERY values). */
dynamicVariableOrder: string[];
}
export function deriveFetchContext(
variables: VariableFormModel[],
): VariableFetchContext {
const dependencyData = computeVariableDependencies(variables);
const variableTypes: Record<string, VariableType> = {};
variables.forEach((v) => {
if (v.name) {
variableTypes[v.name] = v.type;
}
});
const queryVariableOrder = dependencyData.order.filter(
(name) => variableTypes[name] === 'QUERY',
);
const dynamicVariableOrder = variables
.filter((v) => v.type === 'DYNAMIC' && !!v.name)
.map((v) => v.name);
return {
dependencyData,
variableTypes,
queryVariableOrder,
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

@@ -0,0 +1,23 @@
import { isResolved } from '../VariablesBar/selectionUtils';
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`.
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const states = useDashboardStore((s) => s.variableFetchStates);
const selection = useDashboardStore(selectVariableValues(dashboardId));
return names.some((name) => {
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
});
}

View File

@@ -15,12 +15,14 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
import { useIsPanelWaitingOnVariable } from './useIsPanelWaitingOnVariable';
// V1 parity: PER_PAGE_OPTIONS + default page size from V1's list views.
const LIST_PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
@@ -109,9 +111,34 @@ export function usePanelQuery({
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
// Substituted into the request and keyed into the cache so a selection change refetches.
// The full set is substituted into every request, but only the values this panel
// *references* key the cache — so a variable change refetches only the panels that
// use it (V1 parity). Names come from the fetch context (all variables, even
// unresolved ones); null before the variable bar initializes it.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
const referencedVariableNames = useMemo(() => {
const allNames = fetchContext ? Object.keys(fetchContext.variableTypes) : [];
return getReferencedVariables(queries, allNames);
}, [queries, fetchContext]);
const scopedVariables = useMemo(() => {
const scoped: typeof variables = {};
referencedVariableNames.forEach((name) => {
if (variables[name] !== undefined) {
scoped[name] = variables[name];
}
});
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,
);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -186,8 +213,9 @@ export function usePanelQuery({
// Each page is its own cache entry (0/default for non-paged kinds).
offset,
pageSize,
// Variable selection changes the request, so it must re-key the cache (refetch).
variables,
// Only the variables this panel references re-key the cache, so an unrelated
// variable change doesn't refetch it.
scopedVariables,
],
[
panelId,
@@ -203,14 +231,14 @@ export function usePanelQuery({
queries,
offset,
pageSize,
variables,
scopedVariables,
],
);
const response = useGetQueryRangeV5({
requestPayload,
queryKey,
enabled: enabled && runnable,
enabled: enabled && runnable && !isWaitingOnVariable,
});
const queryClient = useQueryClient();

View File

@@ -0,0 +1,75 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import type {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/getAll';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
type VariableFormModel,
type VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
const TYPE_TO_V1: Record<VariableType, TVariableQueryType> = {
QUERY: 'QUERY',
CUSTOM: 'CUSTOM',
TEXT: 'TEXTBOX',
DYNAMIC: 'DYNAMIC',
};
/** Minimal V1-shaped variable — only the fields the shared query builder reads. */
function toV1Variable(model: VariableFormModel): IDashboardVariable {
return {
id: model.name,
name: model.name,
description: model.description,
type: TYPE_TO_V1[model.type],
queryValue: model.queryValue,
customValue: model.customValue,
textboxValue: model.textValue,
sort: 'DISABLED',
multiSelect: model.multiSelect,
showALLOption: model.showAllOption,
dynamicVariablesAttribute: model.dynamicAttribute,
dynamicVariablesSource:
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
? 'all sources'
: model.dynamicSignal,
};
}
/**
* Publishes the V2 dashboard's variables into the shared `dashboardVariablesStore`
* that the query builder's autocomplete (`QuerySearch`) reads, so `$variable`
* suggestions show up in the panel editor and the dashboards-page query builder.
* Suggestion-only — the runtime engine lives in the V2 store. Clears on unmount so
* the shared store doesn't leak into other pages.
*/
export function useSyncVariablesForSuggestions(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): void {
const dashboardId = dashboard?.id ?? '';
const specVariables = dashboard?.spec?.variables;
const variables = useMemo(
() => (specVariables ?? []).map(dtoToFormModel),
[specVariables],
);
useEffect(() => {
if (!dashboardId) {
return undefined;
}
const record: Record<string, IDashboardVariable> = {};
variables.forEach((model) => {
if (model.name) {
record[model.name] = toV1Variable(model);
}
});
setDashboardVariablesStore({ dashboardId, variables: record });
return (): void =>
setDashboardVariablesStore({ dashboardId: '', variables: {} });
}, [dashboardId, variables]);
}

View File

@@ -8,6 +8,7 @@ import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
@@ -55,6 +56,10 @@ function DashboardContainer({
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
// Publish variables to the shared store so the query builder autocomplete
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;

View File

@@ -0,0 +1,47 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
// Envelope spec fields that can carry a variable reference: a builder query's
// filter expression, or a PromQL/ClickHouse query string.
interface ReferenceableSpec {
query?: string;
filter?: { expression?: string };
}
/** Every text string in a panel's queries that could reference a variable. */
function extractQueryTexts(queries: DashboardtypesQueryDTO[]): string[] {
const texts: string[] = [];
toQueryEnvelopes(queries).forEach((envelope) => {
const spec = envelope.spec as ReferenceableSpec | undefined;
if (typeof spec?.query === 'string') {
texts.push(spec.query);
}
if (typeof spec?.filter?.expression === 'string') {
texts.push(spec.filter.expression);
}
});
return texts;
}
/**
* The subset of `variableNames` a panel's queries reference (`$name`, `{{.name}}`,
* `[[name]]`), so a variable change only refetches the panels that actually use it.
* Reuses the shared text-based reference detector over the panel's filter/query text.
*/
export function getReferencedVariables(
queries: DashboardtypesQueryDTO[],
variableNames: string[],
): string[] {
if (queries.length === 0 || variableNames.length === 0) {
return [];
}
const texts = extractQueryTexts(queries);
if (texts.length === 0) {
return [];
}
return variableNames.filter((name) =>
texts.some((text) => textContainsVariableReference(text, name)),
);
}

View File

@@ -0,0 +1,82 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../../DashboardSettings/Variables/variableFormModel';
import { deriveFetchContext } from '../../../VariablesBar/variableDependencies';
import { useDashboardStore } from '../../useDashboardStore';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// q1 (root query) → q2 (query referencing $q1) ; d1 (dynamic).
const q1 = model({ name: 'q1', type: 'QUERY', queryValue: 'SELECT 1' });
const q2 = model({ name: 'q2', type: 'QUERY', queryValue: 'SELECT $q1' });
const d1 = model({ name: 'd1', type: 'DYNAMIC', dynamicAttribute: 'pod' });
const context = deriveFetchContext([q1, q2, d1]);
function store(): ReturnType<typeof useDashboardStore.getState> {
return useDashboardStore.getState();
}
function states(): Record<string, string> {
return store().variableFetchStates;
}
beforeEach(() => {
useDashboardStore.setState({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableFetchContext: null,
});
store().initVariableFetch(['q1', 'q2', 'd1'], context);
});
describe('variableFetchSlice', () => {
it('initializes every variable to idle', () => {
expect(states()).toStrictEqual({ q1: 'idle', q2: 'idle', d1: 'idle' });
});
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
expect(states()).toStrictEqual({
q1: 'loading',
q2: 'waiting',
d1: 'waiting',
});
expect(store().variableCycleIds).toStrictEqual({ q1: 1, q2: 1, d1: 1 });
});
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
store().enqueueFetchAll(true);
expect(states().d1).toBe('loading');
});
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({ q1: 'idle', q2: 'idle', d1: 'loading' });
});
it('enqueueDescendants revalidates only descendants + dynamics', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
store().onVariableFetchComplete('q2');
store().onVariableFetchComplete('d1');
store().enqueueDescendants('q1');
// q2 depends on q1 (settled) → revalidates; d1 waits (q2 no longer settled).
expect(states().q2).toBe('revalidating');
expect(states().d1).toBe('waiting');
});
it('a failed parent idles its query descendants', () => {
store().enqueueFetchAll(false);
store().onVariableFetchFailure('q1');
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
});

View File

@@ -0,0 +1,244 @@
import type { StateCreator } from 'zustand';
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
import type { DashboardStore } from '../useDashboardStore';
import {
areAllQueryVariablesSettled,
type FetchMaps,
isSettled,
resolveFetchState,
unlockWaitingDynamicVariables,
type VariableFetchState,
} from './variableFetchSlice.utils';
export type { 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,
* 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.
*/
export interface VariableFetchSlice {
variableFetchStates: Record<string, VariableFetchState>;
variableLastUpdated: Record<string, number>;
variableCycleIds: Record<string, number>;
/** Static dependency context, set by `initVariableFetch` (null before init). */
variableFetchContext: VariableFetchContext | 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;
/** 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. */
onVariableFetchFailure: (name: string) => void;
/** Cascade a value change to a variable's query descendants + the dynamics. */
enqueueDescendants: (name: string) => void;
}
/** Snapshot the three fetch maps into mutable clones for a single action. */
function cloneMaps(state: DashboardStore): FetchMaps {
return {
states: { ...state.variableFetchStates },
lastUpdated: { ...state.variableLastUpdated },
cycleIds: { ...state.variableCycleIds },
};
}
export const createVariableFetchSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableFetchSlice
> = (set, get) => ({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableFetchContext: null,
initVariableFetch: (names, context): void => {
const maps = cloneMaps(get());
// Initialize new variables to idle, preserving existing states.
names.forEach((name) => {
if (!maps.states[name]) {
maps.states[name] = 'idle';
}
});
// Drop entries for variables that no longer exist.
const nameSet = new Set(names);
Object.keys(maps.states).forEach((name) => {
if (!nameSet.has(name)) {
delete maps.states[name];
delete maps.lastUpdated[name];
delete maps.cycleIds[name];
}
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableFetchContext: context,
});
},
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
}
const {
dependencyData,
variableTypes,
queryVariableOrder,
dynamicVariableOrder,
} = variableFetchContext;
const maps = cloneMaps(get());
// Query variables: roots start immediately, dependents wait for parents.
queryVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
maps.states[name] = hasQueryParents
? 'waiting'
: resolveFetchState(maps, name);
});
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
dynamicVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(maps, name)
: 'waiting';
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
onVariableFetchComplete: (name): void => {
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = 'idle';
maps.lastUpdated[name] = Date.now();
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Unblock waiting query-type children.
(dependencyData.graph[name] || []).forEach((child) => {
if (variableTypes[child] === 'QUERY' && maps.states[child] === 'waiting') {
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({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
onVariableFetchFailure: (name): void => {
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = 'error';
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Query descendants can't proceed without this parent — idle them.
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
maps.states[desc] = 'idle';
}
});
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
enqueueDescendants: (name): void => {
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
}
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
const maps = cloneMaps(get());
// Query descendants: refetch when all their parents are settled, else wait.
(dependencyData.transitiveDescendants[name] || [])
.filter((desc) => variableTypes[desc] === 'QUERY')
.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
? resolveFetchState(maps, desc)
: 'waiting';
});
// Dynamics implicitly depend on all query values: refetch now if the query
// variables are settled, otherwise wait for them.
dynamicVariableOrder.forEach((dynName) => {
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: 'waiting';
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
});
},
});
/** Selector: the fetch state for a single variable (defaults to idle). */
export const selectVariableFetchState =
(name: string) =>
(state: DashboardStore): VariableFetchState =>
state.variableFetchStates[name] ?? 'idle';
/** Selector: the current fetch cycle id for a single variable (defaults to 0). */
export const selectVariableCycleId =
(name: string) =>
(state: DashboardStore): number =>
state.variableCycleIds[name] ?? 0;
/** Selector: whether a variable has completed at least one fetch. */
export const selectVariableFetchedOnce =
(name: string) =>
(state: DashboardStore): boolean =>
(state.variableLastUpdated[name] ?? 0) > 0;

View File

@@ -0,0 +1,51 @@
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
export type VariableFetchState =
| 'idle'
| 'loading'
| 'revalidating'
| 'waiting'
| 'error';
/** Mutable clones a fetch action works over before committing back in one `set`. */
export interface FetchMaps {
states: Record<string, VariableFetchState>;
lastUpdated: Record<string, number>;
cycleIds: Record<string, number>;
}
/** Settled = can make no further progress (idle or error). */
export function isSettled(state: VariableFetchState | undefined): boolean {
return state === 'idle' || state === 'error';
}
/** Fetch-start state: `revalidating` if fetched before, else `loading`. */
export function resolveFetchState(
maps: FetchMaps,
name: string,
): VariableFetchState {
return (maps.lastUpdated[name] || 0) > 0 ? 'revalidating' : 'loading';
}
/** True once every QUERY variable is settled. */
export function areAllQueryVariablesSettled(
states: Record<string, VariableFetchState>,
variableTypes: Record<string, VariableType>,
): boolean {
return Object.entries(variableTypes)
.filter(([, type]) => type === 'QUERY')
.every(([name]) => isSettled(states[name]));
}
/** Move any `waiting` dynamic variables into loading/revalidating. */
export function unlockWaitingDynamicVariables(
maps: FetchMaps,
dynamicVariableOrder: string[],
): void {
dynamicVariableOrder.forEach((dynName) => {
if (maps.states[dynName] === 'waiting') {
maps.states[dynName] = resolveFetchState(maps, dynName);
}
});
}

View File

@@ -13,10 +13,15 @@ import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
import {
createVariableFetchSlice,
type VariableFetchSlice,
} from './slices/variableFetchSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
VariableSelectionSlice &
VariableFetchSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
@@ -31,6 +36,7 @@ export const useDashboardStore = create<DashboardStore>()(
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
...createVariableFetchSlice(...a),
}),
{
name: '@signoz/dashboard-v2',

View File

@@ -20,6 +20,7 @@ import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import styles from './PanelEditorPage.module.scss';
@@ -40,6 +41,9 @@ function PanelEditorPage(): JSX.Element {
});
const dashboard = data?.data;
// Feed variables to the query builder autocomplete inside the editor.
useSyncVariablesForSuggestions(dashboard);
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);