Compare commits

...

12 Commits

Author SHA1 Message Date
Ashwin Bhatkal
ef143064dc fix(dashboard-v2): keep add-variable button out of collapsed bar + flat toolbar borders
- Move the add-variable + icon inside the strip and only show it when the bar
  isn't collapsed (no overflow) or is expanded, so it never breaks the layout
  mid-wrap; expand to reveal it.
- Make the Actions/Configure/Edit-as-JSON border a flat l3 outline with no
  box-shadow/depth.
2026-07-07 11:23:58 +05:30
Ashwin Bhatkal
eafb499dda style(dashboard-v2): use the l3 border on toolbar buttons and the empty state
Give Actions/Configure/Edit-as-JSON and the dashboard empty-state dashed border
the more visible --l3-border.
2026-07-07 11:18:03 +05:30
Ashwin Bhatkal
f127c5d7cc feat(dashboard-v2): collapse the add-variable button once variables exist
Full labelled button when there are no variables; once some exist it shrinks to
a + icon (label on hover) placed after the collapsed +N. Dashed l3 border.
2026-07-07 11:18:03 +05:30
Ashwin Bhatkal
94e6ac0183 feat(dashboard-v2): add-variable button in the variables bar
Add a dashed outlined 'Add variable' button on the left of the variables bar
(shown for editors even with no variables). It deep-links into the settings
drawer at the Variables tab with the add-variable form open, via a transient
settingsRequest store slice; the drawer destroys on close so it re-initializes
cleanly each open.
2026-07-07 11:07:50 +05:30
Ashwin Bhatkal
d28799f2b1 feat(dashboard-v2): restore the V1 combined field/source row for dynamic variables
Replace the separate Source and Attribute rows with the single V1-style row:
the telemetry field on the left, then "from" and the source select on the right.
2026-07-07 10:28:38 +05:30
Ashwin Bhatkal
73ba6a3144 feat(dashboard-v2): show hidden variable values on the collapsed +N hover
When the variables bar collapses the overflow behind +N, hovering it now lists
the hidden variables and their current selected values.
2026-07-07 10:21:40 +05:30
Ashwin Bhatkal
377fd739f2 fix(dashboard-v2): read a variable's default value as string, not { value }
defaultValue is a string | string[] on the wire, but the editor and runtime read
it as { value }, so a saved default showed blank in the form (and couldn't be
cleared) and never auto-applied. Read it directly in all three sites.
2026-07-07 10:18:40 +05:30
Ashwin Bhatkal
9c95cd02b5 fix(dashboard-v2): cap the variable preview-values box height
The 'Preview of Values' box only had a min-height, so a field with many values
grew the box tall. Add a max-height so it scrolls instead.
2026-07-07 10:10:03 +05:30
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
29 changed files with 1091 additions and 109 deletions

View File

@@ -5,3 +5,9 @@
justify-content: flex-end;
gap: 12px;
}
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
.toolbarButton {
border: 1px solid var(--l3-border) !important;
box-shadow: none !important;
}

View File

@@ -63,6 +63,8 @@ function DashboardActions({
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const { safeNavigate } = useSafeNavigate();
@@ -78,6 +80,14 @@ function DashboardActions({
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
useEffect(() => {
if (settingsRequest) {
setIsSettingsDrawerOpen(true);
}
}, [settingsRequest]);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
@@ -252,6 +262,7 @@ function DashboardActions({
variant="solid"
color="secondary"
size="md"
className={styles.toolbarButton}
prefix={<Grid3X3 size="md" />}
testId="options"
>
@@ -263,6 +274,7 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
@@ -273,7 +285,11 @@ function DashboardActions({
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
@@ -282,6 +298,7 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}

View File

@@ -8,6 +8,8 @@ type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
/** Unmount the content on close so it re-initializes on the next open. */
destroyOnClose?: boolean;
}>;
function SettingsDrawer({
@@ -15,6 +17,7 @@ function SettingsDrawer({
drawerTitle,
isOpen,
onClose,
destroyOnClose = false,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
@@ -23,6 +26,7 @@ function SettingsDrawer({
width="50%"
onClose={onClose}
open={isOpen}
destroyOnClose={destroyOnClose}
rootClassName={styles.settingsContainerRoot}
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}

View File

@@ -82,38 +82,14 @@ function DynamicVariableFields({
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
{/* Combined row retained from V1: the field on the left, `from` + the
telemetry source on the right. */}
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
<CustomSelect
className={styles.searchSelect}
className={styles.dynamicFieldSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
placeholder="Select a field"
loading={isLoading}
options={options}
onSearch={setSearch}
@@ -126,6 +102,25 @@ function DynamicVariableFields({
showRetryButton={error ? isRetryableError(error) : true}
data-testid="variable-field-select"
/>
<Typography.Text className={styles.fromText}>from</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
<Select
className={styles.dynamicSourceSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>

View File

@@ -138,7 +138,7 @@
align-items: center;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
padding: 4px 16px !important;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
@@ -157,6 +157,7 @@
.betaTag {
margin-left: 4px;
vertical-align: middle;
}
/* Query */
@@ -264,6 +265,7 @@
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
max-height: 120px;
overflow-y: auto;
}
@@ -329,15 +331,41 @@
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
.searchSelect,
.dynamicFieldSelect,
.dynamicSourceSelect {
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
}
/* Dynamic variable: field | "from" | info | source, retained from V1. */
.dynamicCombinedRow {
display: grid;
grid-template-columns: 1fr auto 16px 180px;
gap: 12px;
align-items: center;
}
.dynamicFieldSelect,
.dynamicSourceSelect {
width: 100%;
min-width: 0;
}
.fromText {
font-family: 'Space Mono';
font-size: 13px;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.actionButtons {
width: 100%;
display: flex;

View File

@@ -46,8 +46,12 @@ export interface UseVariableForm {
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
// `defaultValue` is a string | string[] on the wire; the editor uses a single
// string, so take the first when it's an array.
const readDefaultValue = (model: VariableFormModel): string => {
const dv = model.defaultValue;
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
};
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({

View File

@@ -23,6 +23,11 @@ interface VariablesSettingsProps {
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
// The drawer destroys on close, so reading this once on mount is enough to
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
const openAddOnMount = useDashboardStore(
(s) => s.settingsRequest?.addVariable ?? false,
);
const { save, isSaving } = useSaveVariables();
const initialFormModels = useMemo(
@@ -38,7 +43,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);

View File

@@ -17,6 +17,7 @@ import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './DashboardSettings.module.scss';
interface DashboardSettingsProps {
@@ -38,6 +39,9 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
// Opened once per drawer mount (the drawer destroys on close); a deep-link
// request lands us on the right tab.
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
@@ -69,7 +73,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
);
return (
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>

View File

@@ -47,7 +47,7 @@
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l1-border);
border: 1px dashed var(--l3-border);
border-radius: 6px;
}

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

@@ -1,7 +1,16 @@
.bar {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.addVariable {
flex: none;
border-style: dashed !important;
border-color: var(--l3-border) !important;
}
.strip {
display: flow-root;
}
@@ -112,3 +121,13 @@
box-shadow: none !important;
}
}
.overflowTooltip {
display: flex;
flex-direction: column;
gap: 2px;
}
.overflowName {
font-weight: var(--font-weight-medium);
}

View File

@@ -1,14 +1,34 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { ChevronLeft, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useDashboardStore } from '../store/useDashboardStore';
import type { VariableSelection } from './selectionTypes';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
// Short display of a variable's current selection, for the collapsed +N tooltip.
function formatSelection(selection: VariableSelection | undefined): string {
if (!selection) {
return '—';
}
if (selection.allSelected) {
return 'ALL';
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '—';
}
return value === '' || value === null || value === undefined
? '—'
: String(value);
}
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
@@ -23,8 +43,9 @@ 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 isEditable = useDashboardStore((s) => s.isEditable);
const requestSettings = useDashboardStore((s) => s.requestSettings);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
@@ -33,11 +54,72 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
enabled: !expanded,
});
if (variables.length === 0) {
// Editors can add a variable even before any exist; viewers with no variables
// have nothing to show.
if (variables.length === 0 && !isEditable) {
return null;
}
const openAddVariable = (): void =>
requestSettings({ tab: 'Variables', addVariable: true });
// No variables yet: a full-width labelled button. Once variables exist it
// shrinks to a `+` icon (with the label on hover) that sits after them.
const addVariableFull = (
<Button
variant="outlined"
color="secondary"
size="md"
className={styles.addVariable}
prefix={<Plus size={14} />}
testId="dashboard-variables-add"
onClick={openAddVariable}
>
Add variable
</Button>
);
const addVariableIcon = (
<TooltipSimple side="top" title="Add variable">
<Button
variant="outlined"
color="secondary"
size="icon"
className={styles.addVariable}
aria-label="Add variable"
testId="dashboard-variables-add"
onClick={openAddVariable}
>
<Plus size={14} />
</Button>
</TooltipSimple>
);
const hasOverflow = overflowCount > 0;
const hiddenVariables =
!expanded && hasOverflow ? variables.slice(visibleCount) : [];
const moreButton = (
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
);
if (variables.length === 0) {
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{addVariableFull}
</div>
);
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
@@ -57,7 +139,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
@@ -72,19 +153,31 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
{hasOverflow && (
<span className={styles.moreButton}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
{expanded ? (
moreButton
) : (
<TooltipSimple
side="top"
title={
<div className={styles.overflowTooltip}>
{hiddenVariables.map((variable) => (
<div key={variable.name}>
<span className={styles.overflowName}>{variable.name}</span>:{' '}
{formatSelection(selection[variable.name])}
</div>
))}
</div>
}
>
{moreButton}
</TooltipSimple>
)}
</span>
)}
{/* At the end of the list. Hidden while collapsed (the +N would clash);
expand to reveal it, so the layout never breaks mid-wrap. */}
{isEditable && (!hasOverflow || expanded) && addVariableIcon}
</div>
</div>
);

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

@@ -28,8 +28,8 @@ export function useAutoSelect(
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({

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). */
@@ -28,12 +32,14 @@ const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
// `defaultValue` is a string | string[] on the wire.
const def = model.defaultValue;
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 };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
@@ -45,7 +51,6 @@ function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
@@ -64,27 +69,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 +113,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,39 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/** Which settings-drawer tab to open. Mirrors the DashboardSettings tab keys. */
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
export interface SettingsRequest {
tab: SettingsTab;
/** Open the add-variable form immediately (Variables tab only). */
addVariable?: boolean;
}
/**
* A transient request to open the settings drawer at a given tab/action, so a
* control anywhere in the tree (e.g. the "Add variable" button in the variables
* bar) can deep-link into the drawer without prop-threading. Not persisted;
* cleared when the drawer closes.
*/
export interface SettingsRequestSlice {
settingsRequest: SettingsRequest | null;
requestSettings: (request: SettingsRequest) => void;
clearSettingsRequest: () => void;
}
export const createSettingsRequestSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
SettingsRequestSlice
> = (set) => ({
settingsRequest: null,
requestSettings: (settingsRequest): void => {
set({ settingsRequest });
},
clearSettingsRequest: (): void => {
set({ settingsRequest: null });
},
});

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,20 @@ import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
import {
createVariableFetchSlice,
type VariableFetchSlice,
} from './slices/variableFetchSlice';
import {
createSettingsRequestSlice,
type SettingsRequestSlice,
} from './slices/settingsRequestSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
VariableSelectionSlice &
VariableFetchSlice &
SettingsRequestSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
@@ -31,6 +41,8 @@ export const useDashboardStore = create<DashboardStore>()(
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
...createVariableFetchSlice(...a),
...createSettingsRequestSlice(...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);