mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
12 Commits
feat/dashb
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8b7c330cb | ||
|
|
a949bacc90 | ||
|
|
fbd2272bef | ||
|
|
a5d81f5b56 | ||
|
|
277ba6718f | ||
|
|
744351753b | ||
|
|
b8ac334b3c | ||
|
|
401b3ba98a | ||
|
|
30e3a72438 | ||
|
|
e4694f6411 | ||
|
|
2a1b1516d0 | ||
|
|
91fca0b395 |
@@ -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,
|
||||
});
|
||||
|
||||
@@ -343,6 +343,18 @@
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// 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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -29,6 +37,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
(s) => s.settingsRequest?.addVariable ?? false,
|
||||
);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
const { patchAsync, isPatching } = useOptimisticPatch();
|
||||
|
||||
const initialFormModels = useMemo(
|
||||
() => dashboard.spec.variables.map(dtoToFormModel),
|
||||
@@ -49,6 +58,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
|
||||
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
@@ -64,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 => {
|
||||
@@ -95,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
|
||||
@@ -102,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}
|
||||
/>
|
||||
@@ -115,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}
|
||||
@@ -127,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
.strip {
|
||||
display: flow-root;
|
||||
// Collapsed: one line only, so the trailing add "+" never wraps below.
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stripExpanded {
|
||||
@@ -22,6 +24,7 @@
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
clear: both;
|
||||
|
||||
.variableSlot,
|
||||
|
||||
@@ -52,9 +52,8 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
// Reserve the trailing line space: the "+N" trigger, plus the always-present
|
||||
// add-variable "+" when editable, so the collapse math accounts for both.
|
||||
reserveWidth: isEditable ? 88 : 48,
|
||||
// Reserve room for the "+N" trigger and the add "+" so both stay on one line.
|
||||
reserveWidth: isEditable ? 112 : 48,
|
||||
enabled: !expanded,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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(',')}`;
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user