Compare commits

..

26 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b8b7c330cb Merge branch 'feat/dashboard-v2-variables-redesign' into fix/dashboard-v2-variable-selection 2026-07-08 01:45:07 +05:30
Ashwin Bhatkal
a949bacc90 Merge branch 'feat/dashboard-v2-variable-orchestration' into feat/dashboard-v2-variables-redesign 2026-07-08 01:44:56 +05:30
Ashwin Bhatkal
90e0cb5bb5 Merge branch 'main' into feat/dashboard-v2-variable-orchestration 2026-07-08 01:41:42 +05:30
Ashwin Bhatkal
fbd2272bef fix(dashboard-v2): variable selection edge cases
- Read the __ALL__ URL sentinel as "ALL" only for variables that support it, so a
  literal "__ALL__" value (e.g. a text variable) isn't misread.
- Prune URL selections for variables that no longer exist (rename/remove), so a
  shared link can't leak a stale value into a later variable.
- Bound option-query cache churn with a cacheTime on the query/dynamic fetches.
2026-07-08 01:26:40 +05:30
Ashwin Bhatkal
a5d81f5b56 fix(dashboard-v2): variable fetch-engine correctness
- Ignore a stale/late fetch settle for a variable no longer actively fetching
  (restores V1's active-state guard).
- Unblock a waiting query child only once ALL its parents are settled (diamond
  dependencies no longer fetch against a stale parent).
- A value change no longer refetches the changed variable's own options, and only
  a dynamic change refreshes the other dynamics (query/custom/text changes don't
  touch dynamics — they don't depend on those values).
- Enqueue cycle-dropped query variables as best-effort roots instead of leaving
  them (and any waiting dynamics) silently stuck.
2026-07-08 01:26:40 +05:30
Ashwin Bhatkal
277ba6718f fix(dashboard-v2): variable selector robustness
- Surface option-fetch errors with a retry in the variable bar (Query/Dynamic) —
  a first-fetch failure is no longer a silent, stuck-empty selector.
- Custom variables now auto-select their default/first option (via a CustomSelector
  that runs useAutoSelect) instead of rendering blank.
2026-07-08 01:25:19 +05:30
Ashwin Bhatkal
744351753b fix(dashboard-v2): correct "ALL" variable selection end-to-end
- Materialize a query/custom ALL selection to the full option array so it reaches
  the query payload (was stored as value:null → dropped); tracks option growth.
- Default a multi-select allow-ALL variable (and the __ALL__ sentinel) to ALL,
  matching V1.
- ValueSelector hands CustomMultiSelect the expanded option set when all-selected,
  so it no longer renders a literal __ALL__ row.
- Keep a still-valid multi-select subset when options re-scope (was collapsing to
  the first option).
- Fix the dead configuredDefault fallback (read defaultValue as string|string[]).
2026-07-08 01:25:19 +05:30
Ashwin Bhatkal
b8ac334b3c fix(dashboard-v2): keep the add-variable + on one line when the bar is collapsed 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
401b3ba98a fix(dashboard-v2): center and show the default-value clear icon 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
30e3a72438 fix(dashboard-v2): keep field-key options during search refetch (smooth typing) 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
e4694f6411 fix(dashboard-v2): normalize dynamic variable signal so the source field always shows 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
2a1b1516d0 feat(dashboard-v2): two-column variables table with dynamic apply-to-panels 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
91fca0b395 feat(dashboard-v2): patch builders to apply/sync a dynamic variable's filter across panels 2026-07-08 01:24:32 +05:30
Ashwin Bhatkal
256289614b refactor(dashboard-v2): extract add-variable buttons into components 2026-07-08 01:23:04 +05:30
Ashwin Bhatkal
d1b99c203f feat(dashboard-v2): hide dashboard chrome in full screen
Show only the sections and panels in full screen — the header and toolbar
(actions, variables bar, time selector) are hidden for a clean presentation
view; exit with Esc. Also drops a stray blank line that failed the format check.
2026-07-08 01:23:04 +05:30
Ashwin Bhatkal
4bbef56d00 fix(dashboard-v2): flow the variables bar under the time selector + reposition add button
Revert the bar wrapper to block so the strip wraps around the floated time
selector (one line beside it collapsed; full-width below it when expanded) — a
flex wrapper had made it a BFC that trapped the rows on the left. The add-variable
"+" now sits after the +N/Less trigger, kept inline and always mounted so the
overflow measurement no longer toggles it (which caused a layout shift), with the
collapse math reserving space for it.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
bee2b01d91 feat(dashboard-v2): add a Configure step to the dashboard empty state 2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
fcd7196f37 feat(dashboard-v2): raise dynamic-variable attribute search debounce to 500ms 2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
b66b8167d6 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-08 01:21:45 +05:30
Ashwin Bhatkal
6e74eaee74 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-08 01:21:45 +05:30
Ashwin Bhatkal
cc69ada22c 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-08 01:21:45 +05:30
Ashwin Bhatkal
3ca7b6da89 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-08 01:21:45 +05:30
Ashwin Bhatkal
bf82650c9d 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-08 01:21:45 +05:30
Ashwin Bhatkal
78720ff018 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-08 01:21:45 +05:30
Ashwin Bhatkal
49e5b61362 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-08 01:15:16 +05:30
Ashwin Bhatkal
695d50c270 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-08 01:02:09 +05:30
47 changed files with 1679 additions and 494 deletions

View File

@@ -30,4 +30,6 @@ export const useGetFieldKeys = ({
queryKey: ['fieldKeys', signal, name],
queryFn: () => getFieldKeys(signal, name),
enabled,
// Keep prior keys during a search refetch so the dropdown doesn't blank out.
keepPreviousData: true,
});

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

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { generatePath } from 'react-router-dom';
import {
@@ -59,6 +59,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 { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
@@ -72,6 +74,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,
});
@@ -201,6 +211,7 @@ function DashboardActions({
variant="solid"
color="secondary"
size="md"
className={styles.toolbarButton}
prefix={<Grid3X3 size="md" />}
testId="options"
>
@@ -212,6 +223,7 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
@@ -222,7 +234,11 @@ function DashboardActions({
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
@@ -231,6 +247,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

@@ -40,7 +40,7 @@ function DynamicVariableFields({
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const debouncedSearch = useDebounce(search, 500);
const apiSignal = signalForApi(signal);
const {
@@ -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;
}
@@ -328,16 +330,54 @@
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect,
.dynamicFieldSelect,
.dynamicSourceSelect {
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
// The clear (X) is an SVG, so antd's font-glyph offset mis-centers it — center
// it on the row and make it clearly visible.
:global(.ant-select-clear) {
display: flex;
align-items: center;
justify-content: center;
margin-top: 0;
transform: translateY(-50%);
color: var(--l2-foreground);
opacity: 1;
}
}
/* 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

@@ -1,3 +1,4 @@
import { useRef, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -6,6 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput } from 'antd';
import { CustomMultiSelect } from 'components/NewSelect';
import type { VariableType } from '../variableFormModel';
import DynamicVariableFields from './DynamicVariableFields';
@@ -30,9 +32,19 @@ function VariableForm({
siblings,
isNew,
isSaving,
panelOptions,
appliedPanelIds,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
// The "apply to panels" selection is transient form state, seeded from the
// panels that already reference this variable. Held in a ref so the save
// callback (owned by useVariableForm) reads the latest value.
const [selectedPanelIds, setSelectedPanelIds] =
useState<string[]>(appliedPanelIds);
const selectedPanelIdsRef = useRef(selectedPanelIds);
selectedPanelIdsRef.current = selectedPanelIds;
const {
model,
set,
@@ -54,7 +66,12 @@ function VariableForm({
showAllOptionField,
payloadVariables,
handleSave,
} = useVariableForm({ initial, siblings, isNew, onSave });
} = useVariableForm({
initial,
siblings,
isNew,
onSave: (next): void => onSave(next, selectedPanelIdsRef.current),
});
// Shared list rows (preview/sort/multi/default) for the list-type variables;
// rendered as a sibling inside each list-type panel. Only the active panel
@@ -101,6 +118,20 @@ function VariableForm({
attributeError={attributeError}
/>
{listFields}
<div className={styles.row}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Apply to panels
</Typography.Text>
</div>
<CustomMultiSelect
placeholder="Select panels"
options={panelOptions}
value={selectedPanelIds}
onChange={(value): void => setSelectedPanelIds(value as string[])}
data-testid="variable-apply-panels"
/>
</div>
</div>
</TabsContent>

View File

@@ -3,18 +3,13 @@ import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariableRowProps {
variable: VariableFormModel;
index: number;
@@ -25,9 +20,11 @@ interface VariableRowProps {
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
/** Apply this variable's filter to all panels. Dynamic variables only. */
onApplyToAll: (index: number) => void;
}
/** A single draggable variable row (drag handle + meta + inline actions). */
/** A single draggable variable row in the two-column (name / description) table. */
function VariableRow({
variable,
index,
@@ -37,6 +34,7 @@ function VariableRow({
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onApplyToAll,
}: VariableRowProps): JSX.Element {
const {
attributes,
@@ -61,7 +59,7 @@ function VariableRow({
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<div className={styles.varCell}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
@@ -74,65 +72,94 @@ function VariableRow({
</span>
) : null}
<Typography.Text className={styles.varName}>
${variable.name}
{variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
</div>
<div className={styles.descCell}>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : (
<span className={styles.varDescEmpty}></span>
)}
{canEdit ? (
<div
className={cx(styles.rowActions, {
[styles.rowActionsVisible]: isConfirmingDelete,
})}
>
{isConfirmingDelete ? (
<>
<Typography.Text className={styles.confirmText}>
Delete?
</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</>
) : (
<>
{variable.type === 'DYNAMIC' ? (
<TooltipSimple
side="top"
title="Add this variable as a filter to every panel"
>
<Button
variant="ghost"
color="secondary"
size="sm"
className={styles.applyAllButton}
onClick={(): void => onApplyToAll(index)}
testId={`variable-apply-all-${variable.name}`}
>
Apply to all
</Button>
</TooltipSimple>
) : null}
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</>
)}
</div>
) : null}
</div>
{canEdit && isConfirmingDelete ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && !isConfirmingDelete ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -4,7 +4,7 @@
gap: 16px;
}
.header {
.footer {
display: flex;
justify-content: flex-end;
gap: 16px;
@@ -28,69 +28,125 @@
color: var(--l2-foreground);
}
// Two-column (Variable / Description) table matching the V1 variables listing.
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
.table {
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 6px;
overflow: hidden;
}
.headerRow {
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: 12px 0;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&:first-child {
padding-right: 16px;
border-right: 1px solid var(--l2-border);
}
&:last-child {
padding-left: 16px;
}
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
position: relative;
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
background: var(--l1-background);
&:not(:last-child) {
border-bottom: 1px solid var(--l2-border);
}
&:hover {
background: var(--l2-background);
}
}
.rowMain {
.varCell {
display: flex;
align-items: center;
gap: 10px;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 12px 16px 12px 0;
border-right: 1px solid var(--l2-border);
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
margin-top: 1px;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);
min-width: 0;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-robin-400);
word-break: break-all;
}
.descCell {
position: relative;
min-width: 0;
padding: 12px 0 12px 16px;
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-sienna-400);
white-space: pre-wrap;
word-break: break-word;
}
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
.varDescEmpty {
color: var(--l3-foreground);
}
.rowActions {
position: absolute;
top: 8px;
right: 0;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
padding-left: 16px;
background: var(--l2-background);
opacity: 0;
transition: opacity 120ms ease;
}
.row:hover .rowActions {
opacity: 1;
}
.rowActionsVisible {
opacity: 1;
}
.confirmText {
@@ -98,3 +154,8 @@
font-size: 12px;
color: var(--l2-foreground);
}
.applyAllButton {
font-size: 12px;
white-space: nowrap;
}

View File

@@ -25,6 +25,7 @@ interface VariablesListProps {
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
onApplyToAll: (index: number) => void;
}
function VariablesList({
@@ -36,6 +37,7 @@ function VariablesList({
onConfirmDelete,
onCancelDelete,
onMove,
onApplyToAll,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
@@ -53,32 +55,39 @@ function VariablesList({
};
return (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
<div className={styles.table} data-testid="variables-list">
<div className={styles.headerRow}>
<span className={styles.headerCell}>Variable</span>
<span className={styles.headerCell}>Description</span>
</div>
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
/>
))}
</div>
</SortableContext>
</DndContext>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<div className={styles.list}>
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
onApplyToAll={onApplyToAll}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import type {
DashboardtypesDashboardSpecDTOPanels,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from '../applyVariableToPanelsPatch';
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
function compositePanel(filterExpression: string): unknown {
return {
kind: 'panel',
spec: {
display: { title: '' },
plugin: { kind: 'signoz/TimeSeries', spec: {} },
queries: [
{
kind: 'builder_query',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: filterExpression },
},
},
],
},
},
},
},
],
},
};
}
/** A bare LIST-style BuilderQuery panel (filter lives directly on the plugin spec). */
function listPanel(filterExpression: string): unknown {
return {
kind: 'panel',
spec: {
display: { title: '' },
plugin: { kind: 'signoz/List', spec: {} },
queries: [
{
kind: 'builder_query',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: filterExpression },
},
},
},
},
],
},
};
}
/** A PromQL panel — no builder filter, must be skipped. */
function promqlPanel(): unknown {
return {
kind: 'panel',
spec: {
display: { title: '' },
plugin: { kind: 'signoz/TimeSeries', spec: {} },
queries: [
{
kind: 'promql',
spec: {
plugin: { kind: 'signoz/PromQLQuery', spec: { query: 'up' } },
},
},
],
},
};
}
/** Reads the first builder query's filter expression out of a patch op's value. */
function expressionOf(
op: DashboardtypesJSONPatchOperationDTO,
): string | undefined {
const queries = op.value as Array<{
spec: { plugin: { kind: string; spec: any } };
}>;
const { plugin } = queries[0].spec;
const builderSpec =
plugin.kind === 'signoz/CompositeQuery'
? plugin.spec.queries[0].spec
: plugin.spec;
return builderSpec.filter?.expression;
}
function panels(
map: Record<string, unknown>,
): DashboardtypesDashboardSpecDTOPanels {
return map as DashboardtypesDashboardSpecDTOPanels;
}
describe('buildApplyVariableToPanelsPatch', () => {
it('appends the clause to an existing filter (AND-joined)', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('env = "prod"') }),
'service.name',
'svc',
);
expect(ops).toHaveLength(1);
expect(ops[0]).toMatchObject({
op: 'replace',
path: '/spec/panels/p1/spec/queries',
});
expect(expressionOf(ops[0])).toBe('env = "prod" AND service.name IN $svc');
});
it('sets the clause when the filter is empty', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('') }),
'service.name',
'svc',
);
expect(expressionOf(ops[0])).toBe('service.name IN $svc');
});
it('applies to a bare BuilderQuery (LIST) panel', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: listPanel('') }),
'k8s.pod.name',
'pod',
);
expect(expressionOf(ops[0])).toBe('k8s.pod.name IN $pod');
});
it('is idempotent — re-applying does not duplicate the clause', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('service.name IN $svc') }),
'service.name',
'svc',
);
expect(ops).toHaveLength(0);
});
it('only targets the requested panels', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel(''), p2: compositePanel('') }),
'service.name',
'svc',
['p2'],
);
expect(ops).toHaveLength(1);
expect(ops[0].path).toBe('/spec/panels/p2/spec/queries');
});
it('skips PromQL panels (no builder filter)', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: promqlPanel() }),
'service.name',
'svc',
);
expect(ops).toHaveLength(0);
});
it('returns nothing when attribute or name is missing', () => {
expect(
buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('') }),
'',
'svc',
),
).toHaveLength(0);
});
});
describe('buildSyncVariableToPanelsPatch', () => {
it('adds to selected panels and removes from the rest', () => {
const ops = buildSyncVariableToPanelsPatch(
panels({
p1: compositePanel('service.name IN $svc'), // has it, not selected → remove
p2: compositePanel(''), // selected → add
p3: compositePanel('service.name IN $svc'), // has it, selected → unchanged
}),
'service.name',
'svc',
['p2', 'p3'],
);
const byPath = Object.fromEntries(ops.map((op) => [op.path, op]));
expect(Object.keys(byPath).sort()).toStrictEqual([
'/spec/panels/p1/spec/queries',
'/spec/panels/p2/spec/queries',
]);
expect(expressionOf(byPath['/spec/panels/p1/spec/queries'])).toBe('');
expect(expressionOf(byPath['/spec/panels/p2/spec/queries'])).toBe(
'service.name IN $svc',
);
});
it('removing keeps other clauses intact', () => {
const ops = buildSyncVariableToPanelsPatch(
panels({ p1: compositePanel('env = "prod" AND service.name IN $svc') }),
'service.name',
'svc',
[],
);
expect(expressionOf(ops[0])).toBe('env = "prod"');
});
});
describe('getPanelIdsReferencingVariable', () => {
it('returns only panels whose filter references the variable', () => {
const ids = getPanelIdsReferencingVariable(
panels({
p1: compositePanel('service.name IN $svc'),
p2: compositePanel('env = "prod"'),
p3: listPanel('service.name IN $svc'),
}),
'service.name',
'svc',
);
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
});
});

View File

@@ -0,0 +1,179 @@
import type {
DashboardtypesDashboardSpecDTOPanels,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
// Injects/removes a dynamic variable's filter (`attribute IN $name`) in panel
// builder queries as JSON-Patch ops. Only builder queries carry a filter.
function clauseFor(attribute: string, variableName: string): string {
return `${attribute} IN $${variableName}`;
}
/** Runs `fn` on every builder-query spec in a panel's single query (Composite or bare Builder). */
function forEachBuilderSpec(
queries: DashboardtypesQueryDTO[],
fn: (spec: Querybuildertypesv5BuilderQuerySpecDTO) => void,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}
/** Appends the clause to every builder query's filter. Returns whether anything changed. */
function addClause(queries: DashboardtypesQueryDTO[], clause: string): boolean {
let changed = false;
forEachBuilderSpec(queries, (spec) => {
const existing = spec.filter?.expression?.trim();
// Idempotent: a repeated apply must not stack duplicate clauses.
if (existing?.includes(clause)) {
return;
}
spec.filter = {
expression: existing ? `${existing} AND ${clause}` : clause,
};
changed = true;
});
return changed;
}
/** Removes the managed clause (an ` AND `-joined part) from every builder filter. */
function removeClause(
queries: DashboardtypesQueryDTO[],
clause: string,
): boolean {
let changed = false;
forEachBuilderSpec(queries, (spec) => {
const existing = spec.filter?.expression;
if (!existing) {
return;
}
const parts = existing
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean);
const kept = parts.filter((part) => part !== clause);
if (kept.length !== parts.length) {
spec.filter = { expression: kept.join(' AND ') };
changed = true;
}
});
return changed;
}
/** Whether any builder query in the panel already carries the clause. */
function panelHasClause(
queries: DashboardtypesQueryDTO[],
clause: string,
): boolean {
let has = false;
forEachBuilderSpec(queries, (spec) => {
if (spec.filter?.expression?.includes(clause)) {
has = true;
}
});
return has;
}
function replaceQueriesOp(
panelId: string,
queries: DashboardtypesQueryDTO[],
): DashboardtypesJSONPatchOperationDTO {
return {
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
};
}
/** Add-only: inject the variable's filter into the given panels (default: all). */
export function buildApplyVariableToPanelsPatch(
panels: DashboardtypesDashboardSpecDTOPanels,
attribute: string,
variableName: string,
targetPanelIds?: string[],
): DashboardtypesJSONPatchOperationDTO[] {
if (!attribute || !variableName) {
return [];
}
const clause = clauseFor(attribute, variableName);
const ids = targetPanelIds ?? Object.keys(panels);
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
ids.forEach((id) => {
const panel = panels[id];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
if (addClause(queries, clause)) {
ops.push(replaceQueriesOp(id, queries));
}
});
return ops;
}
/** Full sync: selected panels get the clause; every other panel has it removed. */
export function buildSyncVariableToPanelsPatch(
panels: DashboardtypesDashboardSpecDTOPanels,
attribute: string,
variableName: string,
selectedPanelIds: string[],
): DashboardtypesJSONPatchOperationDTO[] {
if (!attribute || !variableName) {
return [];
}
const clause = clauseFor(attribute, variableName);
const selected = new Set(selectedPanelIds);
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
Object.keys(panels).forEach((id) => {
const panel = panels[id];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
const changed = selected.has(id)
? addClause(queries, clause)
: removeClause(queries, clause);
if (changed) {
ops.push(replaceQueriesOp(id, queries));
}
});
return ops;
}
/** Panel ids whose queries currently reference the variable — pre-populates the picker. */
export function getPanelIdsReferencingVariable(
panels: DashboardtypesDashboardSpecDTOPanels,
attribute: string,
variableName: string,
): string[] {
if (!attribute || !variableName) {
return [];
}
const clause = clauseFor(attribute, variableName);
return Object.keys(panels).filter((id) => {
const queries = panels[id]?.spec?.queries;
return queries?.length ? panelHasClause(queries, clause) : false;
});
}

View File

@@ -0,0 +1,16 @@
.body {
font-size: 13px;
line-height: 20px;
color: var(--l2-foreground);
}
.variableName {
font-family: 'Space Mono', monospace;
color: var(--bg-robin-400);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,67 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import styles from './ApplyToAllDialog.module.scss';
interface ApplyToAllDialogProps {
open: boolean;
variableName: string;
isLoading: boolean;
onConfirm: () => void;
onClose: () => void;
}
/** Confirms applying a dynamic variable as a filter to every panel. */
function ApplyToAllDialog({
open,
variableName,
isLoading,
onConfirm,
onClose,
}: ApplyToAllDialogProps): JSX.Element {
const footer = (
<div className={styles.footer}>
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isLoading}
onClick={onConfirm}
testId="confirm-apply-to-all"
>
<Check size={12} />
Apply to all
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Apply variable to all panels"
width="narrow"
showCloseButton={false}
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
style={{ zIndex: 1100 }}
showOverlay={false}
footer={footer}
>
<div className={styles.body}>
Add <span className={styles.variableName}>${variableName}</span> as a filter
to every panel on this dashboard. Panels that already reference it are left
unchanged.
</div>
</DialogWrapper>
);
}
export default ApplyToAllDialog;

View File

@@ -1,9 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import settingsStyles from '../DashboardSettings.module.scss';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from './applyVariableToPanelsPatch';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
@@ -14,6 +21,7 @@ import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
import { EditingState } from './types';
@@ -23,7 +31,13 @@ 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 { patchAsync, isPatching } = useOptimisticPatch();
const initialFormModels = useMemo(
() => dashboard.spec.variables.map(dtoToFormModel),
@@ -38,10 +52,13 @@ 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,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -57,20 +74,64 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
return variables.filter((_, i) => i !== self);
}, [variables, isEditing]);
const panelOptions = useMemo(
() =>
Object.entries(dashboard.spec.panels ?? {}).map(([id, panel]) => ({
value: id,
label: panel.spec?.display?.name || id,
})),
[dashboard.spec.panels],
);
// Panels the edited variable is already applied to — pre-checks the picker.
const appliedPanelIds = useMemo(() => {
if (!editingFormModel || editingFormModel.type !== 'DYNAMIC') {
return [];
}
return getPanelIdsReferencingVariable(
dashboard.spec.panels,
editingFormModel.dynamicAttribute,
editingFormModel.name,
);
}, [editingFormModel, dashboard.spec.panels]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (Formmodel: VariableFormModel): void => {
const handleFormSave = (
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(Formmodel);
next.push(formModel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = Formmodel;
next[isEditing.index] = formModel;
}
setIsEditing(null);
persist(next);
setVariables(next);
void (async (): Promise<void> => {
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(
dashboard.spec.panels,
formModel.dynamicAttribute,
formModel.name,
selectedPanelIds,
);
if (ops.length === 0) {
return;
}
try {
await patchAsync(ops);
} catch {
toast.error('Could not update panels');
}
})();
};
const handleMove = (from: number, to: number): void => {
@@ -88,6 +149,32 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
const handleConfirmApplyToAll = async (): Promise<void> => {
if (!applyToAllVariable) {
return;
}
const ops = buildApplyVariableToPanelsPatch(
dashboard.spec.panels,
applyToAllVariable.dynamicAttribute,
applyToAllVariable.name,
);
if (ops.length === 0) {
toast.info('No panels needed this filter.');
setApplyToAllIndex(null);
return;
}
try {
await patchAsync(ops);
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
} catch {
toast.error('Could not apply the variable to panels');
}
setApplyToAllIndex(null);
};
if (editingFormModel) {
return (
<VariableForm
@@ -95,6 +182,8 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
siblings={siblings}
isNew={isEditing?.type === 'new'}
isSaving={isSaving}
panelOptions={panelOptions}
appliedPanelIds={appliedPanelIds}
onClose={(): void => setIsEditing(null)}
onSave={handleFormSave}
/>
@@ -108,9 +197,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
) : (
<>
<div className={styles.header}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
<VariablesList
variables={variables}
canEdit={isEditable}
@@ -120,9 +206,20 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
onApplyToAll={(index): void => setApplyToAllIndex(index)}
/>
<div className={styles.footer}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
</>
)}
<ApplyToAllDialog
open={applyToAllVariable !== null}
variableName={applyToAllVariable?.name ?? ''}
isLoading={isPatching}
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
</div>
);
}

View File

@@ -6,6 +6,11 @@ export type EditingState =
| { type: 'edit'; index: number }
| null;
export interface PanelOption {
label: string;
value: string;
}
export interface VariableFormProps {
initial: VariableFormModel;
/** The other variables (excluding this one), for uniqueness & cycle checks. */
@@ -13,6 +18,10 @@ export interface VariableFormProps {
/** True when adding a new variable (enables auto-naming from the attribute). */
isNew: boolean;
isSaving: boolean;
/** All panels, for the dynamic "apply to panels" picker. */
panelOptions: PanelOption[];
/** Panels this variable is already applied to — pre-checks the picker. */
appliedPanelIds: string[];
onClose: () => void;
onSave: (model: VariableFormModel) => void;
onSave: (model: VariableFormModel, selectedPanelIds: string[]) => void;
}

View File

@@ -14,6 +14,7 @@ import type {
import {
DYNAMIC_SIGNAL_ALL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
@@ -68,9 +69,12 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
// Unrecognized/empty signal "all telemetry", so the source always shows.
dynamicSignal: DYNAMIC_SIGNALS.includes(
plugin.spec.signal as DynamicSignalOption,
)
? (plugin.spec.signal as DynamicSignalOption)
: DYNAMIC_SIGNAL_ALL,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).

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

@@ -25,7 +25,6 @@
.welcome {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-weight: 500;
line-height: 24px;
@@ -34,52 +33,56 @@
.welcomeInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
}
.addPanel {
.steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.step {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l1-border);
border: 1px dashed var(--l3-border);
border-radius: 6px;
}
.addPanelText {
.stepText {
display: flex;
align-items: flex-start;
gap: 10px;
.icon {
.stepIcon {
height: 14px;
width: 14px;
margin-top: 2px;
flex: none;
}
}
.addPanelCopy {
.stepCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.addPanelTitle {
.stepTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.addPanelInfo {
.stepInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;

View File

@@ -1,4 +1,4 @@
import { Plus } from '@signozhq/icons';
import { Configure, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
@@ -6,6 +6,7 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import { useCreatePanel } from '../../hooks/useCreatePanel';
import { useDashboardStore } from '../../store/useDashboardStore';
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import styles from './DashboardEmptyState.module.scss';
@@ -18,6 +19,8 @@ function DashboardEmptyState({
}: DashboardEmptyStateProps): JSX.Element {
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isEditable = useDashboardStore((s) => s.isEditable);
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<section className={styles.emptyState}>
@@ -32,28 +35,55 @@ function DashboardEmptyState({
</Typography.Text>
</div>
<div className={styles.addPanel}>
<div className={styles.addPanelText}>
<img src={landscapeUrl} alt="" className={styles.icon} />
<div className={styles.addPanelCopy}>
<Typography.Text className={styles.addPanelTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.addPanelInfo}>
Add panels to visualize your data
</Typography.Text>
<div className={styles.steps}>
<div className={styles.step}>
<div className={styles.stepText}>
<Configure size={14} className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Configure your new dashboard
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Give it a name, add description, tags and variables
</Typography.Text>
</div>
</div>
{isEditable && (
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
)}
</div>
<div className={styles.step}>
<div className={styles.stepText}>
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
</div>
{canAddPanel && (
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
)}
</div>
{canAddPanel && (
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
)}
</div>
</div>
<PanelTypeSelectionModal

View File

@@ -9,7 +9,6 @@ import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
@@ -68,8 +67,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
setIsDeleteOpen(false);
}, [deleteSection]);
const cloneSection = useCloneSection();
const grid = (
<SectionGrid
items={section.items}
@@ -112,7 +109,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onCloneSection: (): void => void cloneSection(section),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { Copy, EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
@@ -10,7 +10,6 @@ interface SectionActionsMenuProps {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
onCloneSection?: () => void;
onDeleteSection?: () => void;
}
@@ -18,7 +17,6 @@ function SectionActionsMenu({
sectionId,
onAddPanel,
onRename,
onCloneSection,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
@@ -39,14 +37,6 @@ function SectionActionsMenu({
onClick: onRename,
});
}
if (onCloneSection) {
result.push({
key: 'clone-section',
icon: <Copy size={14} />,
label: 'Clone section',
onClick: onCloneSection,
});
}
if (onDeleteSection) {
result.push(
{ type: 'divider' },
@@ -60,7 +50,7 @@ function SectionActionsMenu({
);
}
return result;
}, [onAddPanel, onRename, onCloneSection, onDeleteSection]);
}, [onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>

View File

@@ -18,7 +18,6 @@ export interface SectionDragHandle {
export interface SectionHeaderActions {
onRename: () => void;
onAddPanel: () => void;
onCloneSection: () => void;
onDeleteSection: () => void;
}
@@ -82,7 +81,6 @@ function SectionHeader({
sectionId={sectionId}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onCloneSection={actions.onCloneSection}
onDeleteSection={actions.onDeleteSection}
/>
) : null}

View File

@@ -1,55 +0,0 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { cloneSectionOps } from '../../../patchOps';
import type { DashboardSection } from '../../../utils';
/**
* Duplicates a section with all its panels: each panel is deep-copied under a
* fresh id and a new titled Grid ("<name> (Copy)") referencing them is appended,
* as one atomic patch.
*/
export function useCloneSection(): (
section: DashboardSection,
) => Promise<void> {
const { patchAsync } = useOptimisticPatch();
return useCallback(
async (section: DashboardSection): Promise<void> => {
const panels = section.items.flatMap((item) =>
item.panel
? [
{
newId: uuid(),
panel: cloneDeep(item.panel),
x: item.x,
y: item.y,
width: item.width,
height: item.height,
},
]
: [],
);
const title = section.title ? `${section.title} (Copy)` : 'Section (Copy)';
const clone = patchAsync(cloneSectionOps(title, panels));
toast.promise(clone, {
loading: 'Cloning section…',
success: 'Section cloned',
error: 'Failed to clone section',
position: 'top-center',
});
try {
await clone;
} catch {
// toast.promise owns the error UX; the optimistic write + settle handle state.
}
},
[patchAsync],
);
}

View File

@@ -41,17 +41,14 @@ function PanelsAndSectionsLayout({
[layouts, panels],
);
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
// Sectioned mode = at least one titled layout. Sections then become a
// reorderable list; otherwise the dashboard is a single free-flowing grid
// with no section chrome or reordering.
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
// A titled section renders even with no panels (its header + add-panel state);
// only show the dashboard empty state when nothing is titled and there are no panels.
const isEmpty =
sections.length === 0 ||
(!isSectioned && sections.every((s) => s.items.length === 0));
const renderContent = (): ReactNode => {
if (isEmpty) {
return <DashboardEmptyState canAddPanel={isEditable} />;

View File

@@ -0,0 +1,31 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './VariablesBar.module.scss';
/**
* Full-width labelled "Add variable" button shown in the empty state, before any
* variables exist. Opens the Variables settings tab with the add form primed.
*/
function AddVariableFull(): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<Button
variant="outlined"
color="secondary"
size="md"
className={styles.addVariable}
prefix={<Plus size={14} />}
testId="dashboard-variables-add"
onClick={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
Add variable
</Button>
);
}
export default AddVariableFull;

View File

@@ -0,0 +1,35 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './VariablesBar.module.scss';
/**
* Compact "+" trigger (label on hover) shown after the variable pills once at
* least one variable exists. Opens the Variables settings tab with the add form
* primed.
*/
function AddVariableIcon(): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<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={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
<Plus size={14} />
</Button>
</TooltipSimple>
);
}
export default AddVariableIcon;

View File

@@ -1,17 +1,14 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import CustomSelector from './selectors/CustomSelector';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
@@ -22,7 +19,7 @@ interface VariableSelectorProps {
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched fill applied when options resolve (Query/Dynamic auto-selection). */
/** Batched fill applied when options resolve (Query/Dynamic/Custom auto-selection). */
onAutoSelect: (selection: VariableSelection) => void;
}
@@ -35,17 +32,6 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
@@ -81,13 +67,11 @@ function VariableSelector({
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
<CustomSelector
variable={variable}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
onAutoSelect={onAutoSelect}
/>
);
}

View File

@@ -1,9 +1,21 @@
// Block (not flex) so the strip wraps around the floated time selector: one line
// beside it when collapsed, and — via `.stripExpanded`'s `clear: both` — dropping
// full-width below it when expanded. A flex `.bar` would be a BFC that sits beside
// the float and makes the child strip's `clear` inert.
.bar {
min-width: 0;
}
.addVariable {
flex: none;
border-style: dashed !important;
border-color: var(--l3-border) !important;
}
.strip {
display: flow-root;
// Collapsed: one line only, so the trailing add "+" never wraps below.
white-space: nowrap;
}
.stripExpanded {
@@ -12,10 +24,12 @@
gap: 8px;
padding-top: 12px;
overflow: visible;
white-space: normal;
clear: both;
.variableSlot,
.moreButton {
.moreButton,
.addSlot {
margin: 0;
}
@@ -48,6 +62,12 @@
}
.moreButton {
display: inline-flex;
margin-right: 8px;
vertical-align: top;
}
.addSlot {
display: inline-flex;
vertical-align: top;
}

View File

@@ -6,6 +6,9 @@ import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './AddVariableFull';
import AddVariableIcon from './AddVariableIcon';
import type { VariableSelection } from './selectionTypes';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
@@ -44,15 +47,19 @@ interface VariablesBarProps {
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, selection, setSelection, autoSelect } =
useVariableSelection(dashboard);
const isEditable = useDashboardStore((s) => s.isEditable);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
reserveWidth: 48,
// Reserve room for the "+N" trigger and the add "+" so both stay on one line.
reserveWidth: isEditable ? 112 : 48,
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;
}
@@ -74,6 +81,14 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
</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">
<div
@@ -128,6 +143,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
)}
</span>
)}
{/* After the more/less trigger, in every state. Kept inline (not block)
so the row still flows under the floated time selector, and always
mounted so measuring never toggles it. */}
{isEditable && (
<span className={styles.addSlot}>
<AddVariableIcon />
</span>
)}
</div>
</div>
);

View File

@@ -0,0 +1,113 @@
import { renderHook } from '@testing-library/react';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function run(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
): VariableSelection | undefined {
const onAutoSelect = jest.fn();
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
return onAutoSelect.mock.calls[0]?.[0];
}
describe('useAutoSelect', () => {
it('materializes a query ALL selection to the full option array', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b', 'c'],
{ value: null, allSelected: true },
);
expect(next).toStrictEqual({ value: ['a', 'b', 'c'], allSelected: true });
});
it('re-materializes ALL when the options grow', () => {
const next = run(
model({ type: 'CUSTOM', multiSelect: true, showAllOption: true }),
['a', 'b', 'c', 'd'],
{ value: ['a', 'b', 'c'], allSelected: true },
);
expect(next).toStrictEqual({
value: ['a', 'b', 'c', 'd'],
allSelected: true,
});
});
it('leaves a query ALL selection untouched when already the full set', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: ['a', 'b'], allSelected: true },
);
expect(next).toBeUndefined();
});
it('does NOT materialize a dynamic ALL selection (it sends __all__)', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: null, allSelected: true },
);
expect(next).toBeUndefined();
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['a', 'b', 'd'],
{ value: ['a', 'b', 'c'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('re-defaults a multi-select when none of the selected values remain', () => {
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
value: ['a', 'b'],
allSelected: false,
});
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
});
it('auto-selects the default (if present) for a single-select', () => {
const next = run(
model({ type: 'QUERY', defaultValue: 'b' }),
['a', 'b', 'c'],
{ value: '', allSelected: false },
);
expect(next).toStrictEqual({ value: 'b', allSelected: false });
});
it('auto-selects the first option when the default is not available', () => {
const next = run(model({ type: 'QUERY', defaultValue: 'z' }), ['a', 'b'], {
value: '',
allSelected: false,
});
expect(next).toStrictEqual({ value: 'a', allSelected: false });
});
it('leaves a valid single selection untouched', () => {
const next = run(model({ type: 'QUERY' }), ['a', 'b'], {
value: 'b',
allSelected: false,
});
expect(next).toBeUndefined();
});
it('does nothing while options are empty', () => {
const next = run(model({ type: 'QUERY' }), [], {
value: '',
allSelected: false,
});
expect(next).toBeUndefined();
});
});

View File

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

View File

@@ -3,6 +3,7 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -70,7 +71,7 @@ function DynamicSelector({
(s) => s.onVariableFetchFailure,
);
const { data, isFetching } = useQuery(
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
@@ -95,6 +96,8 @@ function DynamicSelector({
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
@@ -117,6 +120,10 @@ function DynamicSelector({
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -3,6 +3,7 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -62,7 +63,7 @@ function QuerySelector({
(s) => s.onVariableFetchFailure,
);
const { data, isFetching } = useQuery(
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable',
variable.name,
@@ -79,6 +80,8 @@ function QuerySelector({
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
@@ -104,6 +107,10 @@ function QuerySelector({
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
@@ -14,6 +13,9 @@ interface ValueSelectorProps {
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
/** Option-fetch error surfaced in the dropdown, with a retry action. */
errorMessage?: string | null;
onRetry?: () => void;
}
/**
@@ -29,6 +31,8 @@ function ValueSelector({
selection,
onChange,
testId,
errorMessage,
onRetry,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
@@ -36,8 +40,11 @@ function ValueSelector({
);
if (multiSelect) {
// All-selected → hand CustomMultiSelect the full option set so it engages its
// "all" path (overlay when closed, every option checked when open). Passing the
// scalar sentinel instead makes it render a literal `__ALL__` row.
const value = selection.allSelected
? ALL_SELECT_VALUE
? options
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
@@ -46,6 +53,8 @@ function ValueSelector({
options={optionData}
value={value}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
@@ -82,6 +91,8 @@ function ValueSelector({
: String(selection.value)
}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
onChange={(next): void =>

View File

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

View File

@@ -33,21 +33,36 @@ export const variablesUrlParser = parseAsJson<
);
function defaultSelection(model: VariableFormModel): VariableSelection {
// `defaultValue` is a string | string[] on the wire.
const def = model.defaultValue;
// Explicit ALL sentinel, or a multi-select allowing ALL with no default → ALL.
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
function fromUrlValue(
raw: SelectedVariableValue,
model: VariableFormModel,
): VariableSelection {
// Only read the `__ALL__` sentinel as "ALL" for variables that support it —
// otherwise a legitimate value of "__ALL__" (e.g. a text var) is taken literally.
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: raw, allSelected: false };
}
interface UseVariableSelection {
@@ -112,7 +127,7 @@ export function useVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
@@ -120,12 +135,27 @@ export function useVariableSelection(
}
});
setVariableValues(dashboardId, seeded);
// Drop URL selections for variables that no longer exist (renamed/removed),
// so a shared link doesn't carry stale entries a later variable could inherit.
if (urlValues) {
const validNames = new Set(variables.map((v) => v.name));
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
if (orphaned) {
const pruned: Record<string, SelectedVariableValue> = {};
Object.entries(urlValues).forEach(([name, value]) => {
if (validNames.has(name)) {
pruned[name] = value;
}
});
void setUrlValues(pruned);
}
}
// 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.
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;

View File

@@ -3,11 +3,7 @@ import type {
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
cloneSectionOps,
createDefaultPanel,
createPanelOps,
} from '../patchOps';
import { createDefaultPanel, createPanelOps } from '../patchOps';
function item(y: number, height: number): DashboardGridItemDTO {
return { x: 0, y, width: 6, height, content: { $ref: '#/spec/panels/x' } };
@@ -144,39 +140,3 @@ describe('createPanelOps', () => {
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
});
});
describe('cloneSectionOps', () => {
const panel = createDefaultPanel('signoz/TimeSeriesPanel');
it('adds a fresh panel per source panel + a titled Grid referencing them, geometry preserved', () => {
const ops = cloneSectionOps('Overview (Copy)', [
{ newId: 'n1', panel, x: 0, y: 0, width: 6, height: 4 },
{ newId: 'n2', panel, x: 6, y: 0, width: 6, height: 4 },
]);
// One add per panel, then the layout append.
expect(ops).toHaveLength(3);
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/n1' });
expect(ops[1]).toMatchObject({ op: 'add', path: '/spec/panels/n2' });
const layoutOp = ops[2];
expect(layoutOp).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
const layout = layoutOp.value as DashboardtypesLayoutDTO;
expect(layout.spec?.display?.title).toBe('Overview (Copy)');
expect(layout.spec?.items).toHaveLength(2);
expect(layout.spec?.items?.[1]).toMatchObject({
x: 6,
y: 0,
width: 6,
height: 4,
content: { $ref: '#/spec/panels/n2' },
});
});
it('produces just the empty titled Grid when the section has no panels', () => {
const ops = cloneSectionOps('Empty (Copy)', []);
expect(ops).toHaveLength(1);
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
expect((ops[0].value as DashboardtypesLayoutDTO).spec?.items).toHaveLength(0);
});
});

View File

@@ -59,12 +59,16 @@ function DashboardContainer({
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
{!fullScreenHandle.active && (
<>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
</>
)}
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
</div>
</FullScreen>

View File

@@ -98,42 +98,6 @@ export function addSectionOp(
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
}
interface ClonedSectionPanel {
newId: string;
/** Deep-copied source panel spec (caller owns the clone). */
panel: DashboardtypesPanelDTO;
x: number;
y: number;
width: number;
height: number;
}
/** Clone a section: add fresh panel copies and append a titled Grid referencing them. */
export function cloneSectionOps(
title: string,
panels: ClonedSectionPanel[],
): DashboardtypesJSONPatchOperationDTO[] {
const panelOps = panels.map((p) => ({
op: add,
path: `/spec/panels/${p.newId}`,
value: p.panel,
}));
const layout: DashboardtypesLayoutDTO = {
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: {
display: { title },
items: panels.map((p) => ({
x: p.x,
y: p.y,
width: p.width,
height: p.height,
content: { $ref: panelRef(p.newId) },
})),
},
};
return [...panelOps, { op: add, path: '/spec/layouts/-', value: layout }];
}
interface AddPanelToSectionArgs {
panelId: string;
panel: DashboardtypesPanelDTO;

View File

@@ -77,17 +77,24 @@ describe('buildVariablesPayload', () => {
});
});
it('falls back to a list variable configured default when unselected', () => {
it('falls back to a string configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', {
defaultValue: { value: 'us-east' },
} as unknown as Partial<VariableFormModel>),
variable('region', 'QUERY', { defaultValue: 'us-east' }),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: 'us-east' },
});
});
it('falls back to an array configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', { defaultValue: ['us-east', 'us-west'] }),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: ['us-east', 'us-west'] },
});
});
it('omits a variable with no selection and no default', () => {
const definitions = [variable('q', 'QUERY')];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});

View File

@@ -40,9 +40,12 @@ function configuredDefault(
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
return (
definition.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
// `defaultValue` is `string | string[]` on the wire — use it directly.
const def = definition.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}
/**

View File

@@ -2,109 +2,145 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../../DashboardSettings/Variables/variableFormModel';
import { deriveFetchContext } from '../../../VariablesBar/variableDependencies';
import {
deriveFetchContext,
type VariableFetchContext,
} from '../../../VariablesBar/variableDependencies';
import { useDashboardStore } from '../../useDashboardStore';
import { VariableFetchState } from '../variableFetchSlice.utils';
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(() => {
function reset(names: string[], context: VariableFetchContext): void {
useDashboardStore.setState({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableFetchContext: null,
});
store().initVariableFetch(['q1', 'q2', 'd1'], context);
});
store().initVariableFetch(names, context);
}
describe('variableFetchSlice', () => {
// q1 (root query) → q2 (query referencing $q1) ; d1, d2 (dynamics).
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 d2 = model({ name: 'd2', type: 'DYNAMIC', dynamicAttribute: 'ns' });
const context = deriveFetchContext([q1, q2, d1, d2]);
beforeEach(() => reset(['q1', 'q2', 'd1', 'd2'], context));
it('initializes every variable to idle', () => {
expect(states()).toStrictEqual({
q1: VariableFetchState.Idle,
q2: VariableFetchState.Idle,
d1: VariableFetchState.Idle,
q1: 'idle',
q2: 'idle',
d1: 'idle',
d2: 'idle',
});
});
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
expect(states()).toStrictEqual({
q1: VariableFetchState.Loading,
q2: VariableFetchState.Waiting,
d1: VariableFetchState.Waiting,
expect(states()).toMatchObject({
q1: 'loading',
q2: 'waiting',
d1: 'waiting',
d2: '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(VariableFetchState.Loading);
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: VariableFetchState.Idle,
q2: VariableFetchState.Loading,
d1: VariableFetchState.Waiting,
});
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({
q1: VariableFetchState.Idle,
q2: VariableFetchState.Idle,
d1: VariableFetchState.Loading,
});
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
});
it('enqueueDescendants revalidates only descendants + dynamics', () => {
store().enqueueFetchAll(false);
it('ignores a settle for a variable that is not actively fetching', () => {
// q1 was never enqueued (still idle) — a stray complete must be a no-op.
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(VariableFetchState.Revalidating);
expect(states().d1).toBe(VariableFetchState.Waiting);
expect(states().q1).toBe('idle');
expect(store().variableLastUpdated.q1 ?? 0).toBe(0);
});
it('enqueueDescendantsBatch bumps each descendant + dynamic exactly once', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
store().onVariableFetchComplete('q2');
store().onVariableFetchComplete('d1');
it('changing a query variable revalidates query descendants but NOT dynamics', () => {
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
// q1 and q2 auto-select together: q2 is a descendant of q1 but is also in
// the batch — it should still bump only once, as should the dynamic.
store().enqueueDescendantsBatch(['q1', 'q2']);
const after = store().variableCycleIds;
expect(after.q2).toBe(before.q2 + 1);
expect(after.d1).toBe(before.d1 + 1);
store().enqueueDescendants('q1');
expect(states().q2).toBe('revalidating');
expect(store().variableCycleIds.q2).toBe(before.q2 + 1);
expect(store().variableCycleIds.d1).toBe(before.d1);
expect(store().variableCycleIds.d2).toBe(before.d2);
});
it('changing a dynamic refreshes the OTHER dynamics, never itself or query vars', () => {
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
store().enqueueDescendants('d1');
expect(store().variableCycleIds.d1).toBe(before.d1);
expect(store().variableCycleIds.d2).toBe(before.d2 + 1);
expect(store().variableCycleIds.q1).toBe(before.q1);
});
it('a failed parent idles its query descendants', () => {
store().enqueueFetchAll(false);
store().onVariableFetchFailure('q1');
expect(states().q1).toBe(VariableFetchState.Error);
expect(states().q2).toBe(VariableFetchState.Idle);
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
});
describe('variableFetchSlice — diamond dependencies', () => {
// qA, qB (roots) → qC (references both $qA and $qB).
const qA = model({ name: 'qA', type: 'QUERY', queryValue: 'SELECT 1' });
const qB = model({ name: 'qB', type: 'QUERY', queryValue: 'SELECT 2' });
const qC = model({ name: 'qC', type: 'QUERY', queryValue: 'SELECT $qA $qB' });
const context = deriveFetchContext([qA, qB, qC]);
beforeEach(() => reset(['qA', 'qB', 'qC'], context));
it('unblocks the child only once BOTH parents are settled', () => {
store().enqueueFetchAll(false);
expect(states().qC).toBe('waiting');
store().onVariableFetchComplete('qA');
expect(states().qC).toBe('waiting'); // qB still loading
store().onVariableFetchComplete('qB');
expect(states().qC).not.toBe('waiting'); // both settled → fetches
});
});
describe('variableFetchSlice — dependency cycle', () => {
// qX ↔ qY reference each other → dropped from the topological order.
const qX = model({ name: 'qX', type: 'QUERY', queryValue: 'SELECT $qY' });
const qY = model({ name: 'qY', type: 'QUERY', queryValue: 'SELECT $qX' });
const context = deriveFetchContext([qX, qY]);
beforeEach(() => reset(['qX', 'qY'], context));
it('enqueues cyclic query variables as best-effort roots (not silently idle)', () => {
store().enqueueFetchAll(false);
expect(states().qX).not.toBe('idle');
expect(states().qY).not.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

@@ -6,6 +6,7 @@ import {
areAllQueryVariablesSettled,
type FetchMaps,
isSettled,
isVariableInActiveFetchState,
resolveFetchState,
unlockWaitingDynamicVariables,
VariableFetchState,
@@ -114,6 +115,17 @@ export const createVariableFetchSlice: StateCreator<
: resolveFetchState(maps, name);
});
// Query variables dropped from the dependency order (part of a cycle) would
// otherwise never fetch and would stall waiting dynamics — start them as
// best-effort roots so they surface data/an error instead of sitting empty.
const orderedQuery = new Set(queryVariableOrder);
Object.keys(variableTypes).forEach((name) => {
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
}
});
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
dynamicVariableOrder.forEach((name) => {
@@ -131,6 +143,11 @@ export const createVariableFetchSlice: StateCreator<
},
onVariableFetchComplete: (name): void => {
// Ignore a stale/late settle for a variable no longer fetching, so a
// superseded cycle can't overwrite fresh state (V1 parity).
if (!isVariableInActiveFetchState(get().variableFetchStates[name])) {
return;
}
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = VariableFetchState.Idle;
@@ -139,12 +156,17 @@ export const createVariableFetchSlice: StateCreator<
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Unblock waiting query-type children.
// Unblock a waiting query child only once ALL its parents are settled —
// otherwise it would fetch against a not-yet-resolved parent.
(dependencyData.graph[name] || []).forEach((child) => {
if (
variableTypes[child] === 'QUERY' &&
maps.states[child] === VariableFetchState.Waiting
variableTypes[child] !== 'QUERY' ||
maps.states[child] !== VariableFetchState.Waiting
) {
return;
}
const parents = dependencyData.parentGraph[child] || [];
if (parents.every((p) => isSettled(maps.states[p]))) {
maps.states[child] = resolveFetchState(maps, child);
}
});
@@ -165,6 +187,9 @@ export const createVariableFetchSlice: StateCreator<
},
onVariableFetchFailure: (name): void => {
if (!isVariableInActiveFetchState(get().variableFetchStates[name])) {
return;
}
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = VariableFetchState.Error;
@@ -205,13 +230,14 @@ export const createVariableFetchSlice: StateCreator<
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
const maps = cloneMaps(get());
const changed = new Set(names);
// Union of the changed variables' query descendants, refreshed once each:
// refetch when all their parents are settled, else wait.
// Union of the changed variables' query descendants (never the changed ones
// themselves), refreshed once each: refetch when all parents are settled.
const queryDescendants = new Set<string>();
names.forEach((name) => {
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
if (variableTypes[desc] === 'QUERY' && !changed.has(desc)) {
queryDescendants.add(desc);
}
});
@@ -225,17 +251,22 @@ export const createVariableFetchSlice: StateCreator<
: VariableFetchState.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)
: VariableFetchState.Waiting;
});
// A dynamic's options depend only on its sibling DYNAMIC selections, so only a
// dynamic change affects them — refresh the *other* dynamics (never the one
// that changed, which would refetch its own identical options).
if (names.some((name) => variableTypes[name] === 'DYNAMIC')) {
dynamicVariableOrder
.filter((dynName) => !changed.has(dynName))
.forEach((dynName) => {
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: VariableFetchState.Waiting;
});
}
set({
variableFetchStates: maps.states,

View File

@@ -21,6 +21,13 @@ export function isSettled(state: VariableFetchState | undefined): boolean {
return state === VariableFetchState.Idle || state === VariableFetchState.Error;
}
/** Active = a fetch is in flight; only then should a settle be applied. */
export function isVariableInActiveFetchState(
state: VariableFetchState | undefined,
): boolean {
return state === 'loading' || state === 'revalidating';
}
/** Fetch-start state: `revalidating` if fetched before, else `loading`. */
export function resolveFetchState(
maps: FetchMaps,

View File

@@ -17,11 +17,16 @@ import {
createVariableFetchSlice,
type VariableFetchSlice,
} from './slices/variableFetchSlice';
import {
createSettingsRequestSlice,
type SettingsRequestSlice,
} from './slices/settingsRequestSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice &
VariableFetchSlice;
VariableFetchSlice &
SettingsRequestSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
@@ -37,6 +42,7 @@ export const useDashboardStore = create<DashboardStore>()(
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
...createVariableFetchSlice(...a),
...createSettingsRequestSlice(...a),
}),
{
name: '@signoz/dashboard-v2',