mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-11 11:20:32 +01:00
Compare commits
6 Commits
nv/list-fi
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736a2a751e | ||
|
|
d01e8aff22 | ||
|
|
6ae1033752 | ||
|
|
0587fe41e4 | ||
|
|
75f5a8b82c | ||
|
|
2f541df60e |
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
import { Select } from 'antd';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: TelemetrySignal;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
function DynamicVariableFields({
|
||||
attribute,
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
[keyData],
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const payload = valueData?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
onPreview(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={signal}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
}
|
||||
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>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
filterOption={isComplete}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableFields;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}
|
||||
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
sort,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
}: QueryVariableFieldsProps): JSX.Element {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.queryContainer}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Query</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.editorWrap}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={(value): void => onChange(value)}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.testRow}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isRunning}
|
||||
disabled={!queryValue}
|
||||
onClick={runTest}
|
||||
testId="variable-test-run"
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryVariableFields;
|
||||
@@ -0,0 +1,310 @@
|
||||
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
|
||||
built on @signozhq components where possible. antd is retained only for the
|
||||
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px 16px 20px;
|
||||
}
|
||||
|
||||
/* VariableItemRow */
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* LabelContainer */
|
||||
.labelContainer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.defaultInput {
|
||||
width: 342px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type segmented group */
|
||||
.typeSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
.queryContainer {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.testRow {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Custom — antd Collapse */
|
||||
.customSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 12px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 1px 2px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.comma-input) {
|
||||
height: 109px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Textbox */
|
||||
.textboxSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Preview strip */
|
||||
.previewSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 3px 0 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
.previewValues {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.previewValues [data-slot='badge'] {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.previewError {
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Sort / multi / all / default rows */
|
||||
.sortSection,
|
||||
.multiSection,
|
||||
.allOptionSection,
|
||||
.dynamicSection {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sortSection {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
width: 339px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.defaultValueSection .label {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.defaultValueDesc {
|
||||
display: block;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.searchSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, Check, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import {
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
import QueryVariableFields from './QueryVariableFields';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** Names of the other variables, for uniqueness validation. */
|
||||
existingNames: string[];
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
|
||||
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
|
||||
* and searchable selects). Master→detail: renders in place of the list.
|
||||
*/
|
||||
function VariableForm({
|
||||
initial,
|
||||
existingNames,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
setDefaultValue(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.allVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.allVariablesBtn}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Name */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Name</Typography.Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={model.name}
|
||||
placeholder="Unique name of the variable"
|
||||
onChange={(e): void => set({ name: e.target.value })}
|
||||
testId="variable-name-input"
|
||||
/>
|
||||
{nameError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{nameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textarea}
|
||||
value={model.description}
|
||||
placeholder="Enter a description for the variable"
|
||||
rows={3}
|
||||
onChange={(e): void => set({ description: e.target.value })}
|
||||
data-testid="variable-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Variable Type */}
|
||||
<VariableTypeSelector value={model.type} onChange={selectType} />
|
||||
|
||||
{/* Type-specific body */}
|
||||
{model.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={(patch): void => set(patch)}
|
||||
onPreview={setPreviewValues}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'QUERY' ? (
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
sort={model.sort}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setPreviewValues}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'CUSTOM' ? (
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{model.type === 'TEXT' ? (
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Shared rows for list-type variables */}
|
||||
{isListType ? (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={model.sort}
|
||||
items={VARIABLE_SORTS.map((sort) => ({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void => {
|
||||
set({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
});
|
||||
}}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => set({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => setDefaultValue(value ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableForm;
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { VariableType } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface VariableTypeSelectorProps {
|
||||
value: VariableType;
|
||||
onChange: (type: VariableType) => void;
|
||||
}
|
||||
|
||||
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
|
||||
function VariableTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: VariableTypeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.row, styles.typeSection)}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.typeBtnGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<Pyramid size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'DYNAMIC',
|
||||
})}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<ClipboardType size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'TEXT',
|
||||
})}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutList size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'CUSTOM',
|
||||
})}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<DatabaseZap size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'QUERY',
|
||||
})}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeSelector;
|
||||
@@ -0,0 +1,101 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.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;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.rowMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.varDesc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PenLine,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariablesListProps {
|
||||
variables: VariableFormModel[];
|
||||
canEdit: boolean;
|
||||
/** Index whose delete is awaiting inline confirmation, if any. */
|
||||
confirmingIndex: number | null;
|
||||
onEdit: (index: number) => void;
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
variables,
|
||||
canEdit,
|
||||
confirmingIndex,
|
||||
onEdit,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
key={variable.name || `variable-${index}`}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && confirmingIndex === index ? (
|
||||
<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 && confirmingIndex !== index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={(): void => onMove(index, index - 1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === variables.length - 1}
|
||||
onClick={(): void => onMove(index, index + 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesList;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableModel';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
type EditingState = { index: number | null } | null;
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialModels = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
|
||||
|
||||
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
|
||||
useEffect(() => {
|
||||
setVariables(initialModels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [editing, setEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editingModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!editing) {
|
||||
return null;
|
||||
}
|
||||
return editing.index === null
|
||||
? emptyVariableFormModel()
|
||||
: variables[editing.index];
|
||||
}, [editing, variables]);
|
||||
|
||||
const existingNames = useMemo(() => {
|
||||
const self = editing?.index ?? null;
|
||||
return variables.filter((_, i) => i !== self).map((v) => v.name);
|
||||
}, [variables, editing]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (model: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (editing?.index == null) {
|
||||
next.push(model);
|
||||
} else {
|
||||
next[editing.index] = model;
|
||||
}
|
||||
setEditing(null);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleMove = (from: number, to: number): void => {
|
||||
if (to < 0 || to >= variables.length) {
|
||||
return;
|
||||
}
|
||||
const next = [...variables];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = (index: number): void => {
|
||||
persist(variables.filter((_, i) => i !== index));
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
// Detail view — edit/new form replaces the list in place (no modal).
|
||||
if (editingModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
initial={editingModel}
|
||||
existingNames={existingNames}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Master view — the variables list.
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Typography.Text className={styles.title}>Variables</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Define variables to parameterize panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setEditing({ index: null })}
|
||||
testId="add-variable"
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variables.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text>No variables defined yet.</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setEditing({ index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesSettings;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
|
||||
interface UseSaveVariables {
|
||||
save: (variables: VariableFormModel[]) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the dashboard's variable list via a single `/spec/variables` patch,
|
||||
* then refetches. Mirrors the General-settings save flow (patch → toast →
|
||||
* refetch → surface errors).
|
||||
*/
|
||||
export function useSaveVariables(): UseSaveVariables {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
async (variables: VariableFormModel[]): Promise<boolean> => {
|
||||
if (!dashboardId) {
|
||||
return false;
|
||||
}
|
||||
const dtos = variables.map(formModelToDto);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
|
||||
toast.success('Variables updated');
|
||||
refetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, refetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { save, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
export function dtoToFormModel(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel {
|
||||
const base = emptyVariableFormModel();
|
||||
const display = dto.spec?.display;
|
||||
const common: VariableFormModel = {
|
||||
...base,
|
||||
name: dto.spec?.name ?? display?.name ?? '',
|
||||
description: display?.description ?? '',
|
||||
hidden: display?.hidden ?? false,
|
||||
};
|
||||
|
||||
// Text variable — a distinct envelope (no list plugin).
|
||||
if (dto.kind === TextEnvelopeKind.TextVariable) {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...common,
|
||||
type: 'TEXT',
|
||||
textValue: spec.value ?? '',
|
||||
textConstant: spec.constant ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const listCommon: VariableFormModel = {
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
|
||||
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'CUSTOM',
|
||||
customValue: plugin.spec.customValue ?? '',
|
||||
};
|
||||
}
|
||||
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'QUERY',
|
||||
queryValue:
|
||||
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
|
||||
? (plugin.spec.queryValue ?? '')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlugin(
|
||||
model: VariableFormModel,
|
||||
): DashboardtypesVariablePluginDTO {
|
||||
switch (model.type) {
|
||||
case 'CUSTOM':
|
||||
return {
|
||||
kind: CustomPluginKind['signoz/CustomVariable'],
|
||||
spec: { customValue: model.customValue },
|
||||
};
|
||||
case 'DYNAMIC':
|
||||
return {
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
default:
|
||||
return {
|
||||
kind: QueryPluginKind['signoz/QueryVariable'],
|
||||
spec: { queryValue: model.queryValue },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat form model → DTO envelope (for persistence). */
|
||||
export function formModelToDto(
|
||||
model: VariableFormModel,
|
||||
): DashboardtypesVariableDTO {
|
||||
const display = {
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
hidden: model.hidden,
|
||||
};
|
||||
|
||||
if (model.type === 'TEXT') {
|
||||
return {
|
||||
kind: TextEnvelopeKind.TextVariable,
|
||||
spec: {
|
||||
name: model.name,
|
||||
display,
|
||||
value: model.textValue,
|
||||
constant: model.textConstant,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: ListEnvelopeKind.ListVariable,
|
||||
spec: {
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
allowAllValue: model.showAllOption,
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
plugin: buildPlugin(model),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
|
||||
export function variableTypeOf(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel['type'] {
|
||||
return dtoToFormModel(dto).type;
|
||||
}
|
||||
|
||||
export { PLUGIN_KIND };
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
|
||||
* to bind a form to; `variableAdapters` converts between this model and the DTO.
|
||||
*/
|
||||
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
|
||||
|
||||
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
/** Wire `kind` discriminators (string values of the generated enums). */
|
||||
export const ENVELOPE_KIND = {
|
||||
LIST: 'ListVariable',
|
||||
TEXT: 'TextVariable',
|
||||
} as const;
|
||||
|
||||
export const PLUGIN_KIND = {
|
||||
QUERY: 'signoz/QueryVariable',
|
||||
CUSTOM: 'signoz/CustomVariable',
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
'logs',
|
||||
'metrics',
|
||||
];
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
hidden: false,
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
|
||||
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
|
||||
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
|
||||
* member sets-or-replaces, so it works whether or not `variables` already exists.
|
||||
*/
|
||||
export function buildVariablesPatch(
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
{
|
||||
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/variables',
|
||||
value: variables,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,48 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import GeneralSettings from './General';
|
||||
import { SettingsTabPlaceholder } from './utils';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
import VariablesSettings from './Variables';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
|
||||
return (
|
||||
<span className={styles.tabLabel}>
|
||||
{icon}
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const items = useMemo(
|
||||
const items: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'general',
|
||||
label: tabLabel(<Table size={14} />, 'General'),
|
||||
label: 'General',
|
||||
children: <GeneralSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Table size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
label: tabLabel(<Braces size={14} />, 'Variables'),
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
|
||||
),
|
||||
label: 'Variables',
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'public-dashboard',
|
||||
label: tabLabel(<Globe size={14} />, 'Publish'),
|
||||
label: 'Publish',
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
prefixIcon: <Globe size={14} />,
|
||||
},
|
||||
],
|
||||
[dashboard],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
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 {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValues(commaValuesParser(variable.customValue), variable.sort).map(
|
||||
String,
|
||||
)
|
||||
: [],
|
||||
[variable],
|
||||
);
|
||||
|
||||
const renderControl = (): JSX.Element => {
|
||||
switch (variable.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
case 'QUERY':
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
return (
|
||||
<DynamicSelector
|
||||
variable={variable}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
return (
|
||||
<ValueSelector
|
||||
options={customOptions}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.variable} data-testid={`variable-${variable.name}`}>
|
||||
<Typography.Text className={styles.label}>${variable.name}</Typography.Text>
|
||||
{renderControl()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,29 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.variable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 160px;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selector bar shown above the panels. Renders one control per
|
||||
* dashboard variable; selections live in the store + URL (never the spec).
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{variables.map((variable) => (
|
||||
<VariableSelector
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesBar;
|
||||
@@ -0,0 +1,16 @@
|
||||
/** A user-selected variable value at runtime (not persisted to the spec). */
|
||||
export type SelectedVariableValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null;
|
||||
|
||||
export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
export type VariableSelectionMap = Record<string, VariableSelection>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
|
||||
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
|
||||
export function isResolved(selection?: VariableSelection): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return true;
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
): Record<string, SelectedVariableValue> {
|
||||
const payload: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(selection).forEach(([name, sel]) => {
|
||||
payload[name] = sel.value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute. (Sibling-dynamic filtering is a later refinement.)
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
selection,
|
||||
onChange,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: variable.dynamicSignal,
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
return sortValues(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSelector;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValues(data.payload.variableValues ?? [], variable.sort).map(
|
||||
String,
|
||||
);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySelector;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface TextSelectorProps {
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/** Free-text variable input. */
|
||||
function TextSelector({
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: TextSelectorProps): JSX.Element {
|
||||
return (
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={typeof selection.value === 'string' ? selection.value : ''}
|
||||
placeholder="Enter a value"
|
||||
onChange={(e): void =>
|
||||
onChange({ value: e.target.value, allSelected: false })
|
||||
}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextSelector;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { ALL_SELECTED } from '../useVariableSelection';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/** Single/multi value picker for Custom/Query/Dynamic variables (options injected). */
|
||||
function ValueSelector({
|
||||
options,
|
||||
multiSelect,
|
||||
showAllOption,
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: ValueSelectorProps): JSX.Element {
|
||||
const items = [
|
||||
...(showAllOption && multiSelect
|
||||
? [{ label: 'ALL', value: ALL_SELECTED }]
|
||||
: []),
|
||||
...options.map((option) => ({ label: option, value: option })),
|
||||
];
|
||||
|
||||
let value: string | string[];
|
||||
if (selection.allSelected) {
|
||||
value = multiSelect ? [ALL_SELECTED] : '';
|
||||
} else if (multiSelect) {
|
||||
value = (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
} else {
|
||||
value = selection.value == null ? '' : String(selection.value);
|
||||
}
|
||||
|
||||
const handleChange = (next: string | string[]): void => {
|
||||
const picksAll =
|
||||
next === ALL_SELECTED ||
|
||||
(Array.isArray(next) && next.includes(ALL_SELECTED));
|
||||
if (showAllOption && multiSelect && picksAll) {
|
||||
onChange({ value: options, allSelected: true });
|
||||
return;
|
||||
}
|
||||
onChange({ value: next, allSelected: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectSimple
|
||||
className={styles.select}
|
||||
items={items}
|
||||
value={value}
|
||||
multiple={multiSelect}
|
||||
loading={loading}
|
||||
placeholder="Select"
|
||||
onChange={handleChange}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueSelector;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onChange({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
return raw === ALL_SELECTED
|
||||
? { value: null, allSelected: true }
|
||||
: { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selection: derives the variable list from the spec, seeds
|
||||
* each value from URL → localStorage(store) → default, and persists changes to
|
||||
* both the store and the URL. Never writes to the dashboard spec.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue);
|
||||
} else if (stored[variable.name]) {
|
||||
seeded[variable.name] = stored[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
* "depends on" another variable when its query text references that variable
|
||||
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
|
||||
* changes, its dependent QUERY variables must refetch. Ported from the V1
|
||||
* dashboard-variables runtime; operates on the V2 flat variable model.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
export interface VariableDependencyData {
|
||||
/** Topological order of variables (parents before children). */
|
||||
order: string[];
|
||||
/** Direct children (dependents) of each variable. */
|
||||
graph: VariableGraph;
|
||||
/** Direct parents of each variable. */
|
||||
parentGraph: VariableGraph;
|
||||
/** All transitive descendants of each variable (precomputed). */
|
||||
transitiveDescendants: VariableGraph;
|
||||
hasCycle: boolean;
|
||||
cycleNodes?: string[];
|
||||
}
|
||||
|
||||
/** Names of QUERY variables whose query references `variableName`. */
|
||||
function getDependents(
|
||||
variableName: string,
|
||||
variables: VariableFormModel[],
|
||||
): string[] {
|
||||
return variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.type === 'QUERY' &&
|
||||
!!v.name &&
|
||||
textContainsVariableReference(v.queryValue || '', variableName),
|
||||
)
|
||||
.map((v) => v.name);
|
||||
}
|
||||
|
||||
/** variable name → its direct dependents (children). */
|
||||
export function buildDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableGraph {
|
||||
const graph: VariableGraph = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
graph[v.name] = getDependents(v.name, variables);
|
||||
}
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert a child graph into a parent graph. */
|
||||
export function buildParentGraph(graph: VariableGraph): VariableGraph {
|
||||
const parents: VariableGraph = {};
|
||||
Object.keys(graph).forEach((node) => {
|
||||
parents[node] = parents[node] ?? [];
|
||||
});
|
||||
Object.entries(graph).forEach(([node, children]) => {
|
||||
children.forEach((child) => {
|
||||
parents[child] = parents[child] ?? [];
|
||||
parents[child].push(node);
|
||||
});
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
function collectCyclePath(
|
||||
graph: VariableGraph,
|
||||
start: string,
|
||||
end: string,
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let current = start;
|
||||
const findParent = (node: string): string | undefined =>
|
||||
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||
while (current !== end) {
|
||||
const parent = findParent(current);
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
return [start, ...path];
|
||||
}
|
||||
|
||||
function detectCycle(
|
||||
graph: VariableGraph,
|
||||
node: string,
|
||||
visited: Set<string>,
|
||||
recStack: Set<string>,
|
||||
): string[] | null {
|
||||
if (!visited.has(node)) {
|
||||
visited.add(node);
|
||||
recStack.add(node);
|
||||
let cycleNodes: string[] | null = null;
|
||||
(graph[node] || []).some((neighbor) => {
|
||||
if (!visited.has(neighbor)) {
|
||||
const found = detectCycle(graph, neighbor, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
} else if (recStack.has(neighbor)) {
|
||||
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (cycleNodes) {
|
||||
return cycleNodes;
|
||||
}
|
||||
}
|
||||
recStack.delete(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
|
||||
export function buildDependencyData(
|
||||
dependencies: VariableGraph,
|
||||
): VariableDependencyData {
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjList: VariableGraph = {};
|
||||
|
||||
Object.keys(dependencies).forEach((node) => {
|
||||
inDegree[node] = inDegree[node] ?? 0;
|
||||
adjList[node] = adjList[node] ?? [];
|
||||
(dependencies[node] || []).forEach((child) => {
|
||||
inDegree[child] = inDegree[child] ?? 0;
|
||||
inDegree[child] += 1;
|
||||
adjList[node].push(child);
|
||||
});
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
let cycleNodes: string[] | undefined;
|
||||
Object.keys(dependencies).some((node) => {
|
||||
if (!visited.has(node)) {
|
||||
const found = detectCycle(dependencies, node, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Topological sort (Kahn's algorithm).
|
||||
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
order.push(current);
|
||||
(adjList[current] || []).forEach((neighbor) => {
|
||||
inDegree[neighbor] -= 1;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasCycle = order.length !== Object.keys(dependencies).length;
|
||||
|
||||
// Transitive descendants: walk topo order in reverse.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const node = order[i];
|
||||
const desc = new Set<string>();
|
||||
(adjList[node] || []).forEach((child) => {
|
||||
desc.add(child);
|
||||
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
|
||||
});
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
graph: adjList,
|
||||
parentGraph: buildParentGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the full dependency data straight from the variable list. */
|
||||
export function computeVariableDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import VariablesBar from './VariablesBar/VariablesBar';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
|
||||
interface DashboardContainerProps {
|
||||
@@ -45,6 +46,7 @@ function DashboardContainer({
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
|
||||
</div>
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../VariablesBar/selectionTypes';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* Runtime variable selection — the values the user picks in the variable bar.
|
||||
* Keyed by dashboardId → variable name. Frontend-only and persisted to
|
||||
* localStorage (mirrored to the URL by the bar for shareable links); it is
|
||||
* deliberately NOT part of the dashboard spec, so selecting a value never
|
||||
* patches the dashboard.
|
||||
*/
|
||||
export interface VariableSelectionSlice {
|
||||
variableValues: Record<string, VariableSelectionMap>;
|
||||
setVariableValue: (
|
||||
dashboardId: string,
|
||||
name: string,
|
||||
selection: VariableSelection,
|
||||
) => void;
|
||||
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
|
||||
setVariableValues: (
|
||||
dashboardId: string,
|
||||
values: VariableSelectionMap,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const createVariableSelectionSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: {
|
||||
...variableValues,
|
||||
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
|
||||
},
|
||||
});
|
||||
},
|
||||
setVariableValues: (dashboardId, values): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the selection map for a dashboard (empty if none). */
|
||||
export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? {};
|
||||
@@ -9,25 +9,36 @@ import {
|
||||
createCollapseSlice,
|
||||
type CollapseSlice,
|
||||
} from './slices/collapseSlice';
|
||||
import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice & CollapseSlice;
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
|
||||
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
|
||||
* - collapse: per-section open state (frontend-only, persisted to localStorage).
|
||||
* - variable-selection: runtime variable values (frontend-only, persisted).
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
// Persist only the collapse map — context (incl. the refetch fn) is transient.
|
||||
partialize: (state) => ({ collapsed: state.collapsed }),
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user