mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
30 Commits
feat/span-
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8b7c330cb | ||
|
|
a949bacc90 | ||
|
|
90e0cb5bb5 | ||
|
|
cec72e25fd | ||
|
|
fbd2272bef | ||
|
|
a5d81f5b56 | ||
|
|
277ba6718f | ||
|
|
744351753b | ||
|
|
b8ac334b3c | ||
|
|
401b3ba98a | ||
|
|
30e3a72438 | ||
|
|
e4694f6411 | ||
|
|
2a1b1516d0 | ||
|
|
91fca0b395 | ||
|
|
256289614b | ||
|
|
d1b99c203f | ||
|
|
4bbef56d00 | ||
|
|
bee2b01d91 | ||
|
|
fcd7196f37 | ||
|
|
b66b8167d6 | ||
|
|
6e74eaee74 | ||
|
|
cc69ada22c | ||
|
|
3ca7b6da89 | ||
|
|
bf82650c9d | ||
|
|
78720ff018 | ||
|
|
49e5b61362 | ||
|
|
695d50c270 | ||
|
|
82c833044d | ||
|
|
f98b8bb1fd | ||
|
|
2114fbddfe |
@@ -1,12 +1,12 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 1000 !important;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 1001 !important;
|
||||
z-index: 60;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -5,3 +5,9 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
|
||||
.toolbarButton {
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import {
|
||||
@@ -59,6 +59,8 @@ function DashboardActions({
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -72,6 +74,14 @@ function DashboardActions({
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
useEffect(() => {
|
||||
if (settingsRequest) {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}
|
||||
}, [settingsRequest]);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
@@ -201,6 +211,7 @@ function DashboardActions({
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
@@ -212,6 +223,7 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
@@ -222,7 +234,11 @@ function DashboardActions({
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
@@ -231,6 +247,7 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
@@ -99,7 +100,7 @@ function DashboardInfo({
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -8,6 +8,8 @@ type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Unmount the content on close so it re-initializes on the next open. */
|
||||
destroyOnClose?: boolean;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
@@ -15,6 +17,7 @@ function SettingsDrawer({
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
destroyOnClose = false,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
@@ -23,6 +26,7 @@ function SettingsDrawer({
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
destroyOnClose={destroyOnClose}
|
||||
rootClassName={styles.settingsContainerRoot}
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
|
||||
@@ -71,6 +72,7 @@ function DashboardInfoForm({
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -40,7 +40,7 @@ function DynamicVariableFields({
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const {
|
||||
@@ -82,38 +82,14 @@ function DynamicVariableFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
{/* Combined row retained from V1: the field on the left, `from` + the
|
||||
telemetry source on the right. */}
|
||||
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
className={styles.dynamicFieldSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
placeholder="Select a field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
onSearch={setSearch}
|
||||
@@ -126,6 +102,25 @@ function DynamicVariableFields({
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
<Typography.Text className={styles.fromText}>from</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
<Select
|
||||
className={styles.dynamicSourceSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
padding: 4px 16px !important;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
@@ -157,6 +157,7 @@
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
@@ -264,6 +265,7 @@
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -328,16 +330,54 @@
|
||||
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect,
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
// The clear (X) is an SVG, so antd's font-glyph offset mis-centers it — center
|
||||
// it on the row and make it clearly visible.
|
||||
:global(.ant-select-clear) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
transform: translateY(-50%);
|
||||
color: var(--l2-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dynamic variable: field | "from" | info | source, retained from V1. */
|
||||
.dynamicCombinedRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 16px 180px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fromText {
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -46,8 +46,12 @@ export interface UseVariableForm {
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
// `defaultValue` is a string | string[] on the wire; the editor uses a single
|
||||
// string, so take the first when it's an array.
|
||||
const readDefaultValue = (model: VariableFormModel): string => {
|
||||
const dv = model.defaultValue;
|
||||
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
|
||||
};
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
|
||||
@@ -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;
|
||||
@@ -4,6 +4,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import styles from './VariableInfoForm.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../../constants';
|
||||
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
|
||||
|
||||
interface VariableInfoFormProps {
|
||||
@@ -30,6 +31,7 @@ function VariableInfoForm({
|
||||
testId="variable-name"
|
||||
className={styles.variableNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
placeholder="Unique name of the variable"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
buildSyncVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from './applyVariableToPanelsPatch';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
@@ -14,6 +21,7 @@ import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
@@ -23,7 +31,13 @@ interface VariablesSettingsProps {
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
(s) => s.settingsRequest?.addVariable ?? false,
|
||||
);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
const { patchAsync, isPatching } = useOptimisticPatch();
|
||||
|
||||
const initialFormModels = useMemo(
|
||||
() => dashboard.spec.variables.map(dtoToFormModel),
|
||||
@@ -38,10 +52,13 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(null);
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(
|
||||
openAddOnMount && isEditable ? { type: 'new' } : null,
|
||||
);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
|
||||
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
@@ -57,20 +74,64 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
return variables.filter((_, i) => i !== self);
|
||||
}, [variables, isEditing]);
|
||||
|
||||
const panelOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(dashboard.spec.panels ?? {}).map(([id, panel]) => ({
|
||||
value: id,
|
||||
label: panel.spec?.display?.name || id,
|
||||
})),
|
||||
[dashboard.spec.panels],
|
||||
);
|
||||
|
||||
// Panels the edited variable is already applied to — pre-checks the picker.
|
||||
const appliedPanelIds = useMemo(() => {
|
||||
if (!editingFormModel || editingFormModel.type !== 'DYNAMIC') {
|
||||
return [];
|
||||
}
|
||||
return getPanelIdsReferencingVariable(
|
||||
dashboard.spec.panels,
|
||||
editingFormModel.dynamicAttribute,
|
||||
editingFormModel.name,
|
||||
);
|
||||
}, [editingFormModel, dashboard.spec.panels]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (Formmodel: VariableFormModel): void => {
|
||||
const handleFormSave = (
|
||||
formModel: VariableFormModel,
|
||||
selectedPanelIds: string[],
|
||||
): void => {
|
||||
const next = [...variables];
|
||||
if (isEditing?.type === 'new') {
|
||||
next.push(Formmodel);
|
||||
next.push(formModel);
|
||||
} else if (isEditing?.type === 'edit') {
|
||||
next[isEditing.index] = Formmodel;
|
||||
next[isEditing.index] = formModel;
|
||||
}
|
||||
setIsEditing(null);
|
||||
persist(next);
|
||||
setVariables(next);
|
||||
void (async (): Promise<void> => {
|
||||
const saved = await save(next);
|
||||
if (!saved || formModel.type !== 'DYNAMIC') {
|
||||
return;
|
||||
}
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
formModel.dynamicAttribute,
|
||||
formModel.name,
|
||||
selectedPanelIds,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
} catch {
|
||||
toast.error('Could not update panels');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleMove = (from: number, to: number): void => {
|
||||
@@ -88,6 +149,32 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
const applyToAllVariable =
|
||||
applyToAllIndex === null ? null : variables[applyToAllIndex];
|
||||
|
||||
const handleConfirmApplyToAll = async (): Promise<void> => {
|
||||
if (!applyToAllVariable) {
|
||||
return;
|
||||
}
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
applyToAllVariable.dynamicAttribute,
|
||||
applyToAllVariable.name,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
toast.info('No panels needed this filter.');
|
||||
setApplyToAllIndex(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
|
||||
} catch {
|
||||
toast.error('Could not apply the variable to panels');
|
||||
}
|
||||
setApplyToAllIndex(null);
|
||||
};
|
||||
|
||||
if (editingFormModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
@@ -95,6 +182,8 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
siblings={siblings}
|
||||
isNew={isEditing?.type === 'new'}
|
||||
isSaving={isSaving}
|
||||
panelOptions={panelOptions}
|
||||
appliedPanelIds={appliedPanelIds}
|
||||
onClose={(): void => setIsEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
@@ -108,9 +197,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
@@ -120,9 +206,20 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
onApplyToAll={(index): void => setApplyToAllIndex(index)}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ApplyToAllDialog
|
||||
open={applyToAllVariable !== null}
|
||||
variableName={applyToAllVariable?.name ?? ''}
|
||||
isLoading={isPatching}
|
||||
onConfirm={(): void => void handleConfirmApplyToAll()}
|
||||
onClose={(): void => setApplyToAllIndex(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
@@ -38,6 +39,9 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
// Opened once per drawer mount (the drawer destroys on close); a deep-link
|
||||
// request lands us on the right tab.
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
@@ -69,7 +73,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
|
||||
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
@@ -86,6 +87,7 @@ function ConfigPane({
|
||||
data-testid="panel-editor-v2-title"
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,60 @@
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Title override. Defaults to the time-range empty-state copy. */
|
||||
title?: string;
|
||||
/** Description override. Defaults to the "widen the range" hint. */
|
||||
description?: string;
|
||||
/** In flight over empty data → show the loader, not the empty state. */
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
* Shared empty-state for panel renderers. The query succeeded but returned nothing,
|
||||
* so we offer to widen the time window — global by default, or the View modal's
|
||||
* local window when it publishes one to the store — alongside a Retry that re-runs
|
||||
* the query.
|
||||
*/
|
||||
function NoData({
|
||||
title = 'No data in this time range',
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PanelMessage
|
||||
icon={<Clock size={18} />}
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
action={extendAction ?? retryAction}
|
||||
secondaryAction={extendAction ? retryAction : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
import { ExtendTimeWindow } from '../extendWindow';
|
||||
import NoData from '../NoData';
|
||||
|
||||
const mockUseExtendTimeWindow = jest.fn();
|
||||
jest.mock('../useExtendTimeWindow', () => ({
|
||||
useExtendTimeWindow: (): ExtendTimeWindow => mockUseExtendTimeWindow(),
|
||||
}));
|
||||
|
||||
const inert: ExtendTimeWindow = {
|
||||
canExtend: false,
|
||||
actionLabel: null,
|
||||
extend: jest.fn(),
|
||||
};
|
||||
|
||||
function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
return {
|
||||
canExtend: true,
|
||||
actionLabel: 'Extend time range',
|
||||
extend: jest.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
useViewPanelStore.setState({ viewPanelExtendWindow: null });
|
||||
});
|
||||
|
||||
it('renders the empty-state title and hint', () => {
|
||||
render(<NoData />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Nothing in the selected window. Try widening the range.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers to extend the window as the primary action', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Extend time range');
|
||||
// No retry handler → no secondary Retry button.
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
const retry = screen.getByTestId('panel-no-data-secondary-action');
|
||||
expect(retry).toHaveTextContent('Retry');
|
||||
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to Retry as the sole action when the window cannot be widened', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(action);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers the View modal extender (store) over the global one', () => {
|
||||
const globalExtend = jest.fn();
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender({ extend: globalExtend }));
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(<NoData />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
expect(globalExtend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no action when nothing can be widened and no retry handler', () => {
|
||||
render(<NoData />);
|
||||
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the panel loader (not the empty state) while refetching', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData isFetching />);
|
||||
|
||||
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honours the data-testid override for the number panel', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData data-testid="number-panel-no-data" />);
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { useExtendTimeWindow } from '../useExtendTimeWindow';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * MINUTE_MS;
|
||||
|
||||
// A window anchored in the past so the zoom-out ladder is deterministic (real
|
||||
// Date.now() is far ahead, so it always center-anchors into a custom range).
|
||||
const PAST_END_MS = 1_600_003_600_000;
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
function globalTimeState(
|
||||
partial: Pick<GlobalReducer, 'selectedTime' | 'minTime' | 'maxTime'>,
|
||||
): GlobalReducer {
|
||||
return {
|
||||
loading: false,
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(globalTime: GlobalReducer): {
|
||||
result: { current: ReturnType<typeof useExtendTimeWindow> };
|
||||
store: ReturnType<typeof mockStore>;
|
||||
} {
|
||||
const store = mockStore({ globalTime });
|
||||
const { result } = renderHook(() => useExtendTimeWindow(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
describe('useExtendTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('reports it can extend and dispatches a wider range', () => {
|
||||
const { result, store } = setup(
|
||||
globalTimeState({
|
||||
selectedTime: '30m',
|
||||
minTime: (PAST_END_MS - 30 * MINUTE_MS) * NS_PER_MS,
|
||||
maxTime: PAST_END_MS * NS_PER_MS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.canExtend).toBe(true);
|
||||
expect(result.current.actionLabel).toBe('Extend time range');
|
||||
|
||||
act(() => result.current.extend());
|
||||
|
||||
const [action] = store.getActions();
|
||||
expect(action.type).toBe(UPDATE_TIME_INTERVAL);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cannot extend past the widest window', () => {
|
||||
const { result, store } = setup(
|
||||
globalTimeState({
|
||||
selectedTime: 'custom',
|
||||
minTime: (PAST_END_MS - 40 * DAY_MS) * NS_PER_MS,
|
||||
maxTime: PAST_END_MS * NS_PER_MS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.canExtend).toBe(false);
|
||||
expect(result.current.actionLabel).toBeNull();
|
||||
|
||||
act(() => result.current.extend());
|
||||
expect(store.getActions()).toHaveLength(0);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ZoomOutResult } from 'lib/zoomOutUtils';
|
||||
|
||||
export interface ExtendTimeWindow {
|
||||
canExtend: boolean;
|
||||
/** Button copy; null when the window can't be widened further. */
|
||||
actionLabel: string | null;
|
||||
extend: () => void;
|
||||
}
|
||||
|
||||
const EXTEND_ACTION_LABEL = 'Extend time range';
|
||||
|
||||
/**
|
||||
* Adapts a zoom-out ladder result (`getNextZoomOutRange`) plus its apply callback
|
||||
* into the panel empty-state extender. A `null` result means the window is already
|
||||
* at the widest ladder step, so there is nothing to extend to.
|
||||
*/
|
||||
export function buildExtendWindow(
|
||||
result: ZoomOutResult | null,
|
||||
extend: () => void,
|
||||
): ExtendTimeWindow {
|
||||
return {
|
||||
canExtend: result !== null,
|
||||
actionLabel: result ? EXTEND_ACTION_LABEL : null,
|
||||
extend,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// eslint-disable-next-line no-restricted-imports -- global time lives in redux; V2 time dispatch is not yet migrated
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { buildExtendWindow, ExtendTimeWindow } from './extendWindow';
|
||||
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
|
||||
|
||||
/**
|
||||
* Default empty-state extender: widen the dashboard's global time via the shared
|
||||
* zoom-out ladder. Dispatch + URL sync live in `useZoomOut`; the View modal
|
||||
* overrides this with its own local extender via the store.
|
||||
*/
|
||||
export function useExtendTimeWindow(): ExtendTimeWindow {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const extend = useZoomOut();
|
||||
const result = getNextZoomOutRange(
|
||||
Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
);
|
||||
return buildExtendWindow(result, extend);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Centred full-body spinner (first-fetch + refetch-over-empty).
|
||||
.loader {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
|
||||
import styles from './PanelLoader.module.scss';
|
||||
|
||||
interface PanelLoaderProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** Centred full-panel spinner shown while a query is in flight with nothing to display yet. */
|
||||
function PanelLoader({
|
||||
'data-testid': testId = 'panel-loading',
|
||||
}: PanelLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loader} data-testid={testId}>
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelLoader;
|
||||
@@ -45,6 +45,10 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface PanelMessageProps {
|
||||
description?: string;
|
||||
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
|
||||
action?: PanelMessageAction;
|
||||
/** Second call-to-action rendered beside `action` (e.g. Extend + Retry). */
|
||||
secondaryAction?: PanelMessageAction;
|
||||
/** `danger` tints the icon for failure states; `neutral` for empty states. */
|
||||
tone?: 'neutral' | 'danger';
|
||||
'data-testid'?: string;
|
||||
@@ -34,9 +36,18 @@ function PanelMessage({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
tone = 'neutral',
|
||||
'data-testid': testId,
|
||||
}: PanelMessageProps): JSX.Element {
|
||||
const actions: Array<{ suffix: string; action: PanelMessageAction }> = [];
|
||||
if (action) {
|
||||
actions.push({ suffix: 'action', action });
|
||||
}
|
||||
if (secondaryAction) {
|
||||
actions.push({ suffix: 'secondary-action', action: secondaryAction });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.message} data-testid={testId}>
|
||||
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
|
||||
@@ -48,18 +59,22 @@ function PanelMessage({
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={action.icon}
|
||||
onClick={action.onClick}
|
||||
className={styles.action}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
{actions.length > 0 && (
|
||||
<div className={styles.actions}>
|
||||
{actions.map(({ suffix, action: item }) => (
|
||||
<Button
|
||||
key={suffix}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={item.icon}
|
||||
onClick={item.onClick}
|
||||
data-testid={testId ? `${testId}-${suffix}` : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,4 +43,28 @@ describe('PanelMessage', () => {
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders both actions side by side and wires each onClick', () => {
|
||||
const onPrimary = jest.fn();
|
||||
const onSecondary = jest.fn();
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={null}
|
||||
title="No data in this time range"
|
||||
action={{ label: 'Extend time range', onClick: onPrimary }}
|
||||
secondaryAction={{ label: 'Retry', onClick: onSecondary }}
|
||||
data-testid="panel-no-data"
|
||||
/>,
|
||||
);
|
||||
|
||||
const primary = screen.getByTestId('panel-no-data-action');
|
||||
const secondary = screen.getByTestId('panel-no-data-secondary-action');
|
||||
expect(primary).toHaveTextContent('Extend time range');
|
||||
expect(secondary).toHaveTextContent('Retry');
|
||||
|
||||
fireEvent.click(primary);
|
||||
fireEvent.click(secondary);
|
||||
expect(onPrimary).toHaveBeenCalledTimes(1);
|
||||
expect(onSecondary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -186,7 +187,9 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -25,6 +25,7 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -104,7 +105,9 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -33,6 +33,7 @@ function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
@@ -142,7 +143,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -24,6 +24,7 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -125,7 +126,11 @@ function NumberPanelRenderer({
|
||||
: {})}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
<NoData
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -27,6 +27,7 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -94,7 +95,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -35,6 +35,7 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
onClick,
|
||||
@@ -159,7 +160,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -35,6 +35,7 @@ function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -187,7 +188,9 @@ function TimeSeriesPanelRenderer({
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
.welcome {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
@@ -34,52 +33,56 @@
|
||||
|
||||
.welcomeInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.addPanel {
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border: 1px dashed var(--l3-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.addPanelText {
|
||||
.stepText {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.icon {
|
||||
.stepIcon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.addPanelCopy {
|
||||
.stepCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.addPanelTitle {
|
||||
.stepTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.addPanelInfo {
|
||||
.stepInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Configure, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
@@ -6,6 +6,7 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
|
||||
import landscapeUrl from '@/assets/Icons/landscape.svg';
|
||||
|
||||
import { useCreatePanel } from '../../hooks/useCreatePanel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
|
||||
@@ -18,6 +19,8 @@ function DashboardEmptyState({
|
||||
}: DashboardEmptyStateProps): JSX.Element {
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
return (
|
||||
<section className={styles.emptyState}>
|
||||
@@ -32,28 +35,55 @@ function DashboardEmptyState({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.addPanel}>
|
||||
<div className={styles.addPanelText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.icon} />
|
||||
<div className={styles.addPanelCopy}>
|
||||
<Typography.Text className={styles.addPanelTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.addPanelInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
// Generic centred body — used by the loading indicator.
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Container for the rendered chart — fills the panel below the header and lets
|
||||
// the chart shrink (min-* 0) so it resizes with the grid cell.
|
||||
.chartContainer {
|
||||
.panelContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import PanelLoader from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
|
||||
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
@@ -106,17 +106,13 @@ function PanelBody({
|
||||
}
|
||||
|
||||
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
|
||||
// mounted (e.g. list page change) with the header carrying the in-flight indicator.
|
||||
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
|
||||
if (isFetching && !hasData) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chartContainer}>
|
||||
<div className={styles.panelContainer}>
|
||||
<panelDefinition.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -6,6 +6,7 @@ import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useViewPanelStore } from 'pages/DashboardPageV2/DashboardContainer/store/useViewPanelStore';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
@@ -38,6 +39,7 @@ function ViewPanelModalContent({
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
@@ -69,6 +71,15 @@ function ViewPanelModalContent({
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Publish the modal's local extender for the nested no-data state; cleared on close.
|
||||
const setViewPanelExtendWindow = useViewPanelStore(
|
||||
(s) => s.setViewPanelExtendWindow,
|
||||
);
|
||||
useEffect(() => {
|
||||
setViewPanelExtendWindow(extendWindow);
|
||||
return (): void => setViewPanelExtendWindow(null);
|
||||
}, [extendWindow, setViewPanelExtendWindow]);
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
|
||||
@@ -6,11 +6,15 @@ import type {
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
buildExtendWindow,
|
||||
ExtendTimeWindow,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/components/NoData/extendWindow';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
|
||||
|
||||
export interface ViewPanelTimeWindow {
|
||||
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
|
||||
@@ -26,6 +30,8 @@ export interface ViewPanelTimeWindow {
|
||||
refreshWindow: () => void;
|
||||
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Widens this local window in place — powers the empty-state "extend" action. */
|
||||
extendWindow: ExtendTimeWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,8 +51,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
>(selectedTime as Time);
|
||||
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
startMs: Math.floor(minTime / NS_PER_MS),
|
||||
endMs: Math.floor(maxTime / NS_PER_MS),
|
||||
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -64,8 +70,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
// GetMinMax returns nanoseconds — convert to the ms window we work in.
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -78,8 +84,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
}
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
});
|
||||
}, [selectedInterval]);
|
||||
|
||||
@@ -95,6 +101,22 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
setTimeOverride({ startMs, endMs });
|
||||
}, []);
|
||||
|
||||
// Empty-state extender that widens this local window via onTimeChange (not global time).
|
||||
const extendWindow = useMemo<ExtendTimeWindow>(() => {
|
||||
const result = getNextZoomOutRange(timeOverride.startMs, timeOverride.endMs);
|
||||
return buildExtendWindow(result, (): void => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [startMs, endMs] = result.range;
|
||||
if (result.preset) {
|
||||
onTimeChange(result.preset);
|
||||
} else {
|
||||
onTimeChange('custom', [startMs, endMs]);
|
||||
}
|
||||
});
|
||||
}, [timeOverride, onTimeChange]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
timeOverride,
|
||||
@@ -102,7 +124,15 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
}),
|
||||
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
|
||||
[
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
|
||||
onTimeChange: jest.fn(),
|
||||
refreshWindow: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
extendWindow: { canExtend: false, actionLabel: null, extend: jest.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -90,4 +90,35 @@ describe('useViewPanelTimeWindow', () => {
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual(initial);
|
||||
});
|
||||
|
||||
it('widens the local window in place via the zoom-out ladder', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
act(() => result.current.onDragSelect(1000, 5000));
|
||||
|
||||
expect(result.current.extendWindow.canExtend).toBe(true);
|
||||
expect(result.current.extendWindow.actionLabel).toBe('Extend time range');
|
||||
|
||||
act(() => result.current.extendWindow.extend());
|
||||
|
||||
// A past 4s window (< 15m) zooms out 3× around its centre (3000): [-3000, 9000].
|
||||
expect(result.current.selectedInterval).toBe('custom');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: -3000,
|
||||
endMs: 9000,
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot extend a window already at the widest ladder step', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
// 40 days > the ladder's 1-month max.
|
||||
act(() => result.current.onDragSelect(0, 40 * 24 * 60 * 60 * 1000));
|
||||
|
||||
expect(result.current.extendWindow.canExtend).toBe(false);
|
||||
expect(result.current.extendWindow.actionLabel).toBeNull();
|
||||
|
||||
// extend() is a no-op — the window is unchanged.
|
||||
const before = result.current.timeOverride;
|
||||
act(() => result.current.extendWindow.extend());
|
||||
expect(result.current.timeOverride).toStrictEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
|
||||
interface SectionTitleModalProps {
|
||||
open: boolean;
|
||||
/** Modal heading, e.g. "Rename section" / "New section". */
|
||||
@@ -56,7 +58,7 @@ function SectionTitleModal({
|
||||
testId="section-title-input"
|
||||
autoFocus
|
||||
value={value}
|
||||
maxLength={120}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder={placeholder}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
/**
|
||||
* Full-width labelled "Add variable" button shown in the empty state, before any
|
||||
* variables exist. Opens the Variables settings tab with the add form primed.
|
||||
*/
|
||||
function AddVariableFull(): JSX.Element {
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.addVariable}
|
||||
prefix={<Plus size={14} />}
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
requestSettings({ tab: 'Variables', addVariable: true })
|
||||
}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddVariableFull;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
/**
|
||||
* Compact "+" trigger (label on hover) shown after the variable pills once at
|
||||
* least one variable exists. Opens the Variables settings tab with the add form
|
||||
* primed.
|
||||
*/
|
||||
function AddVariableIcon(): JSX.Element {
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
return (
|
||||
<TooltipSimple side="top" title="Add variable">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariable}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
requestSettings({ tab: 'Variables', addVariable: true })
|
||||
}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddVariableIcon;
|
||||
@@ -1,51 +1,37 @@
|
||||
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 {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched fill applied when options resolve (Query/Dynamic/Custom auto-selection). */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
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':
|
||||
@@ -61,10 +47,10 @@ function VariableSelector({
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
@@ -75,18 +61,17 @@ function VariableSelector({
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
// Block (not flex) so the strip wraps around the floated time selector: one line
|
||||
// beside it when collapsed, and — via `.stripExpanded`'s `clear: both` — dropping
|
||||
// full-width below it when expanded. A flex `.bar` would be a BFC that sits beside
|
||||
// the float and makes the child strip's `clear` inert.
|
||||
.bar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.addVariable {
|
||||
flex: none;
|
||||
border-style: dashed !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
.strip {
|
||||
display: flow-root;
|
||||
// Collapsed: one line only, so the trailing add "+" never wraps below.
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stripExpanded {
|
||||
@@ -12,10 +24,12 @@
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
clear: both;
|
||||
|
||||
.variableSlot,
|
||||
.moreButton {
|
||||
.moreButton,
|
||||
.addSlot {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -48,6 +62,12 @@
|
||||
}
|
||||
|
||||
.moreButton {
|
||||
display: inline-flex;
|
||||
margin-right: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.addSlot {
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -112,3 +132,13 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.overflowName {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import AddVariableFull from './AddVariableFull';
|
||||
import AddVariableIcon from './AddVariableIcon';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
// Short display of a variable's current selection, for the collapsed +N tooltip.
|
||||
function formatSelection(selection: VariableSelection | undefined): string {
|
||||
if (!selection) {
|
||||
return '—';
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return 'ALL';
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '—';
|
||||
}
|
||||
return value === '' || value === null || value === undefined
|
||||
? '—'
|
||||
: String(value);
|
||||
}
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
@@ -23,21 +45,49 @@ interface VariablesBarProps {
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
reserveWidth: 48,
|
||||
// Reserve room for the "+N" trigger and the add "+" so both stay on one line.
|
||||
reserveWidth: isEditable ? 112 : 48,
|
||||
enabled: !expanded,
|
||||
});
|
||||
|
||||
if (variables.length === 0) {
|
||||
// Editors can add a variable even before any exist; viewers with no variables
|
||||
// have nothing to show.
|
||||
if (variables.length === 0 && !isEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasOverflow = overflowCount > 0;
|
||||
const hiddenVariables =
|
||||
!expanded && hasOverflow ? variables.slice(visibleCount) : [];
|
||||
|
||||
const moreButton = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
<AddVariableFull />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
@@ -57,7 +107,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
<VariableSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
@@ -66,23 +115,41 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
onAutoSelect={(next): void => autoSelect(variable.name, next)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
<span className={styles.moreButton}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
moreButton
|
||||
) : (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title={
|
||||
<div className={styles.overflowTooltip}>
|
||||
{hiddenVariables.map((variable) => (
|
||||
<div key={variable.name}>
|
||||
<span className={styles.overflowName}>{variable.name}</span>:{' '}
|
||||
{formatSelection(selection[variable.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{moreButton}
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
{isEditable && (
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -10,12 +12,14 @@ import {
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
@@ -25,12 +29,16 @@ interface DynamicSelectorProps {
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
|
||||
* the runtime fetch engine: dynamics fetch together once the query variables have
|
||||
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
@@ -38,6 +46,7 @@ function DynamicSelector({
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -48,14 +57,53 @@ function DynamicSelector({
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching, error, refetch } = useQuery(
|
||||
[
|
||||
'dashboard-variable-dynamic',
|
||||
variable.name,
|
||||
variable.dynamicSignal,
|
||||
variable.dynamicAttribute,
|
||||
existingQuery,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
getFieldValues(
|
||||
signalForApi(variable.dynamicSignal),
|
||||
variable.dynamicAttribute,
|
||||
undefined,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery || undefined,
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
!!variable.dynamicAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
@@ -64,14 +112,18 @@ function DynamicSelector({
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
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,63 +3,90 @@ 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';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (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).
|
||||
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
|
||||
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
|
||||
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
|
||||
* per-variable `cycleId` keys the request — so a parent's value change refetches
|
||||
* only the dependent variables, in dependency order. The current selections feed
|
||||
* the request payload but are deliberately NOT in the key (V1 parity).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: 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(
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching, error, refetch } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
{
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@@ -72,14 +99,18 @@ function QuerySelector({
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
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,41 +1,99 @@
|
||||
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,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
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 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,
|
||||
});
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
selectVariableCycleId,
|
||||
selectVariableFetchedOnce,
|
||||
selectVariableFetchState,
|
||||
VariableFetchState,
|
||||
} from '../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
export interface VariableFetchStateResult {
|
||||
variableFetchState: VariableFetchState;
|
||||
/** Include in the selector's react-query key to auto-cancel stale requests. */
|
||||
variableFetchCycleId: number;
|
||||
/** Actively fetching (first load or revalidating). */
|
||||
isVariableFetching: boolean;
|
||||
/** Stable — the fetch completed (or errored). */
|
||||
isVariableSettled: boolean;
|
||||
/** Blocked on parent dependencies (query order) or query variables (dynamics). */
|
||||
isVariableWaiting: boolean;
|
||||
/** Completed at least one fetch — keeps the query subscribed so a cycle bump refetches. */
|
||||
hasVariableFetchedOnce: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variable view of the runtime fetch engine (`variableFetchSlice`), consumed
|
||||
* by the Query/Dynamic selectors to gate their fetch and key it by cycle id.
|
||||
* V2-native equivalent of V1's `useVariableFetchState`.
|
||||
*/
|
||||
export function useVariableFetchState(name: string): VariableFetchStateResult {
|
||||
const variableFetchState = useDashboardStore(selectVariableFetchState(name));
|
||||
const variableFetchCycleId = useDashboardStore(selectVariableCycleId(name));
|
||||
const hasVariableFetchedOnce = useDashboardStore(
|
||||
selectVariableFetchedOnce(name),
|
||||
);
|
||||
|
||||
return {
|
||||
variableFetchState,
|
||||
variableFetchCycleId,
|
||||
isVariableFetching:
|
||||
variableFetchState === VariableFetchState.Loading ||
|
||||
variableFetchState === VariableFetchState.Revalidating,
|
||||
isVariableSettled: variableFetchState === VariableFetchState.Idle,
|
||||
isVariableWaiting: variableFetchState === VariableFetchState.Waiting,
|
||||
hasVariableFetchedOnce,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
@@ -12,8 +16,8 @@ import type {
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
deriveFetchContext,
|
||||
doAllQueryVariablesHaveValues,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
@@ -29,26 +33,48 @@ export const variablesUrlParser = parseAsJson<
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
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 {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
/**
|
||||
* Auto-selection fill (default/first-option) applied when options arrive. Unlike
|
||||
* {@link UseVariableSelection.setSelection}, fills from the initial load burst are
|
||||
* coalesced into one store write + one downstream refresh.
|
||||
*/
|
||||
autoSelect: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,33 +91,43 @@ export function useVariableSelection(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
|
||||
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
|
||||
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
|
||||
const enqueueDescendantsBatch = useDashboardStore(
|
||||
(s) => s.enqueueDescendantsBatch,
|
||||
);
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// Latest selection, read by the fetch-cycle effect without subscribing to it
|
||||
// (so a value change doesn't re-trigger a full fetch cycle).
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
// Seed selections: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
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 {
|
||||
@@ -99,19 +135,101 @@ 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. A value
|
||||
// change instead goes through `enqueueDescendants`, not this effect.
|
||||
const orderKey = `${fetchContext.queryVariableOrder.join(
|
||||
',',
|
||||
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const names = variables
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
initVariableFetch(names, fetchContext);
|
||||
enqueueFetchAll(
|
||||
doAllQueryVariablesHaveValues(variables, selectionRef.current),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, orderKey, minTime, maxTime]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
enqueueDescendants(name);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
// Coalesce the initial load burst of auto-selections: each selector fills its
|
||||
// value as its options resolve (at different times). Collecting them into one
|
||||
// store write + one `enqueueDescendantsBatch` means dependents re-fetch once
|
||||
// with the settled parent values, instead of once per fill.
|
||||
const pendingAutoFillRef = useRef<VariableSelectionMap>({});
|
||||
const autoFillFrameRef = useRef<number | null>(null);
|
||||
|
||||
const flushAutoFills = useCallback((): void => {
|
||||
autoFillFrameRef.current = null;
|
||||
const fills = pendingAutoFillRef.current;
|
||||
pendingAutoFillRef.current = {};
|
||||
const names = Object.keys(fills);
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
|
||||
void setUrlValues((prev) => {
|
||||
const next = { ...(prev ?? {}) };
|
||||
names.forEach((name) => {
|
||||
const sel = fills[name];
|
||||
next[name] = sel.allSelected ? ALL_SELECTED : sel.value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
enqueueDescendantsBatch(names);
|
||||
}, [dashboardId, setVariableValues, setUrlValues, enqueueDescendantsBatch]);
|
||||
|
||||
const autoSelect = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
pendingAutoFillRef.current[name] = next;
|
||||
if (autoFillFrameRef.current == null) {
|
||||
autoFillFrameRef.current = requestAnimationFrame(flushAutoFills);
|
||||
}
|
||||
},
|
||||
[flushAutoFills],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (autoFillFrameRef.current != null) {
|
||||
cancelAnimationFrame(autoFillFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { variables, selection, setSelection, autoSelect };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
VariableFormModel,
|
||||
VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
import { isResolved } from './selectionUtils';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
@@ -197,3 +202,57 @@ export function computeVariableDependencies(
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
|
||||
/**
|
||||
* Static context the runtime fetch engine (`variableFetchSlice`) needs to order
|
||||
* fetches: the dependency graph plus the per-name type index and the QUERY /
|
||||
* DYNAMIC fetch orders. Derived from the variable definitions; stable until the
|
||||
* spec's variables change. Mirrors V1's `getVariableDependencyContext`.
|
||||
*/
|
||||
export interface VariableFetchContext {
|
||||
dependencyData: VariableDependencyData;
|
||||
/** variable name → its type. */
|
||||
variableTypes: Record<string, VariableType>;
|
||||
/** QUERY variables in topological (parent-before-child) order. */
|
||||
queryVariableOrder: string[];
|
||||
/** DYNAMIC variable names (they implicitly depend on all QUERY values). */
|
||||
dynamicVariableOrder: string[];
|
||||
}
|
||||
|
||||
export function deriveFetchContext(
|
||||
variables: VariableFormModel[],
|
||||
): VariableFetchContext {
|
||||
const dependencyData = computeVariableDependencies(variables);
|
||||
const variableTypes: Record<string, VariableType> = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
variableTypes[v.name] = v.type;
|
||||
}
|
||||
});
|
||||
const queryVariableOrder = dependencyData.order.filter(
|
||||
(name) => variableTypes[name] === 'QUERY',
|
||||
);
|
||||
const dynamicVariableOrder = variables
|
||||
.filter((v) => v.type === 'DYNAMIC' && !!v.name)
|
||||
.map((v) => v.name);
|
||||
return {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
queryVariableOrder,
|
||||
dynamicVariableOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every QUERY variable already has a usable selection — decides at load
|
||||
* time whether dynamic variables may fetch immediately or must wait for the
|
||||
* query variables to settle first (V1 parity).
|
||||
*/
|
||||
export function doAllQueryVariablesHaveValues(
|
||||
variables: VariableFormModel[],
|
||||
selection: VariableSelectionMap,
|
||||
): boolean {
|
||||
return variables
|
||||
.filter((v) => v.type === 'QUERY')
|
||||
.every((v) => isResolved(selection[v.name]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Max length for dashboard / section / panel / variable name inputs. */
|
||||
export const DASHBOARD_NAME_MAX_LENGTH = 128;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isResolved } from '../VariablesBar/selectionUtils';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* True while a panel should stay in its loading state because a variable it
|
||||
* references is still loading/waiting and has no usable value yet — i.e. the
|
||||
* first load. Once the variable has a value, a later change no longer blocks the
|
||||
* panel (it refetches over stale data instead). V1 parity with
|
||||
* `useIsPanelWaitingOnVariable`.
|
||||
*/
|
||||
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const states = useDashboardStore((s) => s.variableFetchStates);
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
|
||||
return names.some((name) => {
|
||||
const state = states[name];
|
||||
const inFlight =
|
||||
state === 'loading' || state === 'revalidating' || state === 'waiting';
|
||||
return isResolved(selection[name]) ? false : inFlight;
|
||||
});
|
||||
}
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
} from '../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
|
||||
import { getRawResults } from '../queryV5/v5ResponseData';
|
||||
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
|
||||
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
|
||||
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
|
||||
import { useIsPanelWaitingOnVariable } from './useIsPanelWaitingOnVariable';
|
||||
|
||||
// V1 parity: PER_PAGE_OPTIONS + default page size from V1's list views.
|
||||
const LIST_PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
|
||||
@@ -111,9 +113,34 @@ export function usePanelQuery({
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Resolved variable values for this dashboard, published by useResolvedVariables.
|
||||
// Substituted into the request and keyed into the cache so a selection change refetches.
|
||||
// The full set is substituted into every request, but only the values this panel
|
||||
// *references* key the cache — so a variable change refetches only the panels that
|
||||
// use it (V1 parity). Names come from the fetch context (all variables, even
|
||||
// unresolved ones); null before the variable bar initializes it.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
|
||||
|
||||
const referencedVariableNames = useMemo(() => {
|
||||
const allNames = fetchContext ? Object.keys(fetchContext.variableTypes) : [];
|
||||
return getReferencedVariables(queries, allNames);
|
||||
}, [queries, fetchContext]);
|
||||
|
||||
const scopedVariables = useMemo(() => {
|
||||
const scoped: typeof variables = {};
|
||||
referencedVariableNames.forEach((name) => {
|
||||
if (variables[name] !== undefined) {
|
||||
scoped[name] = variables[name];
|
||||
}
|
||||
});
|
||||
return scoped;
|
||||
}, [variables, referencedVariableNames]);
|
||||
|
||||
// First-load gate: hold the panel in its loading state until every referenced
|
||||
// variable has resolved a value.
|
||||
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
|
||||
referencedVariableNames,
|
||||
);
|
||||
|
||||
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
|
||||
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
|
||||
@@ -188,8 +215,9 @@ export function usePanelQuery({
|
||||
// Each page is its own cache entry (0/default for non-paged kinds).
|
||||
offset,
|
||||
pageSize,
|
||||
// Variable selection changes the request, so it must re-key the cache (refetch).
|
||||
variables,
|
||||
// Only the variables this panel references re-key the cache, so an unrelated
|
||||
// variable change doesn't refetch it.
|
||||
scopedVariables,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
@@ -205,14 +233,14 @@ export function usePanelQuery({
|
||||
queries,
|
||||
offset,
|
||||
pageSize,
|
||||
variables,
|
||||
scopedVariables,
|
||||
],
|
||||
);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled: enabled && runnable,
|
||||
enabled: enabled && runnable && !isWaitingOnVariable,
|
||||
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
|
||||
keepPreviousData: isPaginated,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import type {
|
||||
IDashboardVariable,
|
||||
TVariableQueryType,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
const TYPE_TO_V1: Record<VariableType, TVariableQueryType> = {
|
||||
QUERY: 'QUERY',
|
||||
CUSTOM: 'CUSTOM',
|
||||
TEXT: 'TEXTBOX',
|
||||
DYNAMIC: 'DYNAMIC',
|
||||
};
|
||||
|
||||
/** Minimal V1-shaped variable — only the fields the shared query builder reads. */
|
||||
function toV1Variable(model: VariableFormModel): IDashboardVariable {
|
||||
return {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
type: TYPE_TO_V1[model.type],
|
||||
queryValue: model.queryValue,
|
||||
customValue: model.customValue,
|
||||
textboxValue: model.textValue,
|
||||
sort: 'DISABLED',
|
||||
multiSelect: model.multiSelect,
|
||||
showALLOption: model.showAllOption,
|
||||
dynamicVariablesAttribute: model.dynamicAttribute,
|
||||
dynamicVariablesSource:
|
||||
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
|
||||
? 'all sources'
|
||||
: model.dynamicSignal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the V2 dashboard's variables into the shared `dashboardVariablesStore`
|
||||
* that the query builder's autocomplete (`QuerySearch`) reads, so `$variable`
|
||||
* suggestions show up in the panel editor and the dashboards-page query builder.
|
||||
* Suggestion-only — the runtime engine lives in the V2 store. Clears on unmount so
|
||||
* the shared store doesn't leak into other pages.
|
||||
*/
|
||||
export function useSyncVariablesForSuggestions(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): void {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const specVariables = dashboard?.spec?.variables;
|
||||
const variables = useMemo(
|
||||
() => (specVariables ?? []).map(dtoToFormModel),
|
||||
[specVariables],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
return undefined;
|
||||
}
|
||||
const record: Record<string, IDashboardVariable> = {};
|
||||
variables.forEach((model) => {
|
||||
if (model.name) {
|
||||
record[model.name] = toV1Variable(model);
|
||||
}
|
||||
});
|
||||
setDashboardVariablesStore({ dashboardId, variables: record });
|
||||
return (): void =>
|
||||
setDashboardVariablesStore({ dashboardId: '', variables: {} });
|
||||
}, [dashboardId, variables]);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
@@ -51,15 +52,23 @@ function DashboardContainer({
|
||||
// the store, so each panel's query substitutes the bar's selected values.
|
||||
useResolvedVariables(dashboard);
|
||||
|
||||
// Publish variables to the shared store so the query builder autocomplete
|
||||
// suggests them ($variable) in the panel editor and dashboards-page builder.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
{!fullScreenHandle.active && (
|
||||
<>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
</div>
|
||||
</FullScreen>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { toQueryEnvelopes } from './buildQueryRangeRequest';
|
||||
|
||||
// Envelope spec fields that can carry a variable reference: a builder query's
|
||||
// filter expression, or a PromQL/ClickHouse query string.
|
||||
interface ReferenceableSpec {
|
||||
query?: string;
|
||||
filter?: { expression?: string };
|
||||
}
|
||||
|
||||
/** Every text string in a panel's queries that could reference a variable. */
|
||||
function extractQueryTexts(queries: DashboardtypesQueryDTO[]): string[] {
|
||||
const texts: string[] = [];
|
||||
toQueryEnvelopes(queries).forEach((envelope) => {
|
||||
const spec = envelope.spec as ReferenceableSpec | undefined;
|
||||
if (typeof spec?.query === 'string') {
|
||||
texts.push(spec.query);
|
||||
}
|
||||
if (typeof spec?.filter?.expression === 'string') {
|
||||
texts.push(spec.filter.expression);
|
||||
}
|
||||
});
|
||||
return texts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `variableNames` a panel's queries reference (`$name`, `{{.name}}`,
|
||||
* `[[name]]`), so a variable change only refetches the panels that actually use it.
|
||||
* Reuses the shared text-based reference detector over the panel's filter/query text.
|
||||
*/
|
||||
export function getReferencedVariables(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
variableNames: string[],
|
||||
): string[] {
|
||||
if (queries.length === 0 || variableNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const texts = extractQueryTexts(queries);
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return variableNames.filter((name) =>
|
||||
texts.some((text) => textContainsVariableReference(text, name)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import {
|
||||
deriveFetchContext,
|
||||
type VariableFetchContext,
|
||||
} from '../../../VariablesBar/variableDependencies';
|
||||
import { useDashboardStore } from '../../useDashboardStore';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
function store(): ReturnType<typeof useDashboardStore.getState> {
|
||||
return useDashboardStore.getState();
|
||||
}
|
||||
function states(): Record<string, string> {
|
||||
return store().variableFetchStates;
|
||||
}
|
||||
function reset(names: string[], context: VariableFetchContext): void {
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableFetchContext: null,
|
||||
});
|
||||
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: 'idle',
|
||||
q2: 'idle',
|
||||
d1: 'idle',
|
||||
d2: 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
expect(states()).toMatchObject({
|
||||
q1: 'loading',
|
||||
q2: 'waiting',
|
||||
d1: 'waiting',
|
||||
d2: 'waiting',
|
||||
});
|
||||
});
|
||||
|
||||
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
|
||||
store().enqueueFetchAll(true);
|
||||
expect(states().d1).toBe('loading');
|
||||
});
|
||||
|
||||
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
|
||||
|
||||
store().onVariableFetchComplete('q2');
|
||||
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
|
||||
});
|
||||
|
||||
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');
|
||||
expect(states().q1).toBe('idle');
|
||||
expect(store().variableLastUpdated.q1 ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/** Which settings-drawer tab to open. Mirrors the DashboardSettings tab keys. */
|
||||
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
|
||||
|
||||
export interface SettingsRequest {
|
||||
tab: SettingsTab;
|
||||
/** Open the add-variable form immediately (Variables tab only). */
|
||||
addVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transient request to open the settings drawer at a given tab/action, so a
|
||||
* control anywhere in the tree (e.g. the "Add variable" button in the variables
|
||||
* bar) can deep-link into the drawer without prop-threading. Not persisted;
|
||||
* cleared when the drawer closes.
|
||||
*/
|
||||
export interface SettingsRequestSlice {
|
||||
settingsRequest: SettingsRequest | null;
|
||||
requestSettings: (request: SettingsRequest) => void;
|
||||
clearSettingsRequest: () => void;
|
||||
}
|
||||
|
||||
export const createSettingsRequestSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
SettingsRequestSlice
|
||||
> = (set) => ({
|
||||
settingsRequest: null,
|
||||
requestSettings: (settingsRequest): void => {
|
||||
set({ settingsRequest });
|
||||
},
|
||||
clearSettingsRequest: (): void => {
|
||||
set({ settingsRequest: null });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
import {
|
||||
areAllQueryVariablesSettled,
|
||||
type FetchMaps,
|
||||
isSettled,
|
||||
isVariableInActiveFetchState,
|
||||
resolveFetchState,
|
||||
unlockWaitingDynamicVariables,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
export { VariableFetchState } from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Runtime fetch orchestration for dashboard variables — native port of V1's
|
||||
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
|
||||
* variables in dependency order, dynamics together once query values exist,
|
||||
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
|
||||
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
|
||||
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
|
||||
*/
|
||||
export interface VariableFetchSlice {
|
||||
variableFetchStates: Record<string, VariableFetchState>;
|
||||
variableLastUpdated: Record<string, number>;
|
||||
variableCycleIds: Record<string, number>;
|
||||
/** Static dependency context, set by `initVariableFetch` (null before init). */
|
||||
variableFetchContext: VariableFetchContext | null;
|
||||
|
||||
/** Seed state entries for the current variable set and store the context. */
|
||||
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
|
||||
/** Start a full fetch cycle for every fetchable variable (load / time change). */
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
|
||||
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
|
||||
onVariableFetchComplete: (name: string) => void;
|
||||
/** Mark a variable's fetch as failed; idle its query descendants. */
|
||||
onVariableFetchFailure: (name: string) => void;
|
||||
/** Cascade a value change to a variable's query descendants + the dynamics. */
|
||||
enqueueDescendants: (name: string) => void;
|
||||
/**
|
||||
* Batched value-change cascade: refresh the union of the given variables'
|
||||
* query descendants plus the dynamics, each exactly once. Used to collapse the
|
||||
* initial burst of auto-selections into a single downstream fetch.
|
||||
*/
|
||||
enqueueDescendantsBatch: (names: string[]) => void;
|
||||
}
|
||||
|
||||
/** Snapshot the three fetch maps into mutable clones for a single action. */
|
||||
function cloneMaps(state: DashboardStore): FetchMaps {
|
||||
return {
|
||||
states: { ...state.variableFetchStates },
|
||||
lastUpdated: { ...state.variableLastUpdated },
|
||||
cycleIds: { ...state.variableCycleIds },
|
||||
};
|
||||
}
|
||||
|
||||
export const createVariableFetchSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableFetchSlice
|
||||
> = (set, get) => ({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableFetchContext: null,
|
||||
|
||||
initVariableFetch: (names, context): void => {
|
||||
const maps = cloneMaps(get());
|
||||
// Initialize new variables to idle, preserving existing states.
|
||||
names.forEach((name) => {
|
||||
if (!maps.states[name]) {
|
||||
maps.states[name] = VariableFetchState.Idle;
|
||||
}
|
||||
});
|
||||
// Drop entries for variables that no longer exist.
|
||||
const nameSet = new Set(names);
|
||||
Object.keys(maps.states).forEach((name) => {
|
||||
if (!nameSet.has(name)) {
|
||||
delete maps.states[name];
|
||||
delete maps.lastUpdated[name];
|
||||
delete maps.cycleIds[name];
|
||||
}
|
||||
});
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableFetchContext: context,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
queryVariableOrder,
|
||||
dynamicVariableOrder,
|
||||
} = variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
|
||||
// Query variables: roots start immediately, dependents wait for parents.
|
||||
queryVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[name] || [];
|
||||
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
|
||||
maps.states[name] = hasQueryParents
|
||||
? VariableFetchState.Waiting
|
||||
: 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) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
maps.states[name] = doAllQueryVariablesHaveValuesSelected
|
||||
? resolveFetchState(maps, name)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
maps.lastUpdated[name] = Date.now();
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// 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
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parents = dependencyData.parentGraph[child] || [];
|
||||
if (parents.every((p) => isSettled(maps.states[p]))) {
|
||||
maps.states[child] = resolveFetchState(maps, child);
|
||||
}
|
||||
});
|
||||
// Once all query variables settle, unlock any waiting dynamics.
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
onVariableFetchFailure: (name): void => {
|
||||
if (!isVariableInActiveFetchState(get().variableFetchStates[name])) {
|
||||
return;
|
||||
}
|
||||
const { variableFetchContext } = get();
|
||||
const maps = cloneMaps(get());
|
||||
maps.states[name] = VariableFetchState.Error;
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Query descendants can't proceed without this parent — idle them.
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
if (variableTypes[desc] === 'QUERY') {
|
||||
maps.states[desc] = VariableFetchState.Idle;
|
||||
}
|
||||
});
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueDescendants: (name): void => {
|
||||
get().enqueueDescendantsBatch([name]);
|
||||
},
|
||||
|
||||
enqueueDescendantsBatch: (names): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext || names.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
const changed = new Set(names);
|
||||
|
||||
// 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' && !changed.has(desc)) {
|
||||
queryDescendants.add(desc);
|
||||
}
|
||||
});
|
||||
});
|
||||
queryDescendants.forEach((desc) => {
|
||||
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[desc] || [];
|
||||
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
|
||||
maps.states[desc] = allParentsSettled
|
||||
? resolveFetchState(maps, desc)
|
||||
: 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,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the fetch state for a single variable (defaults to idle). */
|
||||
export const selectVariableFetchState =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): VariableFetchState =>
|
||||
state.variableFetchStates[name] ?? VariableFetchState.Idle;
|
||||
|
||||
/** Selector: the current fetch cycle id for a single variable (defaults to 0). */
|
||||
export const selectVariableCycleId =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): number =>
|
||||
state.variableCycleIds[name] ?? 0;
|
||||
|
||||
/** Selector: whether a variable has completed at least one fetch. */
|
||||
export const selectVariableFetchedOnce =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): boolean =>
|
||||
(state.variableLastUpdated[name] ?? 0) > 0;
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
|
||||
export enum VariableFetchState {
|
||||
Idle = 'idle',
|
||||
Loading = 'loading',
|
||||
Revalidating = 'revalidating',
|
||||
Waiting = 'waiting',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
/** Mutable clones a fetch action works over before committing back in one `set`. */
|
||||
export interface FetchMaps {
|
||||
states: Record<string, VariableFetchState>;
|
||||
lastUpdated: Record<string, number>;
|
||||
cycleIds: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Settled = can make no further progress (idle or error). */
|
||||
export function isSettled(state: VariableFetchState | undefined): boolean {
|
||||
return state === 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,
|
||||
name: string,
|
||||
): VariableFetchState {
|
||||
return (maps.lastUpdated[name] || 0) > 0
|
||||
? VariableFetchState.Revalidating
|
||||
: VariableFetchState.Loading;
|
||||
}
|
||||
|
||||
/** True once every QUERY variable is settled. */
|
||||
export function areAllQueryVariablesSettled(
|
||||
states: Record<string, VariableFetchState>,
|
||||
variableTypes: Record<string, VariableType>,
|
||||
): boolean {
|
||||
return Object.entries(variableTypes)
|
||||
.filter(([, type]) => type === 'QUERY')
|
||||
.every(([name]) => isSettled(states[name]));
|
||||
}
|
||||
|
||||
/** Move any `waiting` dynamic variables into loading/revalidating. */
|
||||
export function unlockWaitingDynamicVariables(
|
||||
maps: FetchMaps,
|
||||
dynamicVariableOrder: string[],
|
||||
): void {
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
if (maps.states[dynName] === VariableFetchState.Waiting) {
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,10 +13,20 @@ import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
import {
|
||||
createVariableFetchSlice,
|
||||
type VariableFetchSlice,
|
||||
} from './slices/variableFetchSlice';
|
||||
import {
|
||||
createSettingsRequestSlice,
|
||||
type SettingsRequestSlice,
|
||||
} from './slices/settingsRequestSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
VariableSelectionSlice &
|
||||
VariableFetchSlice &
|
||||
SettingsRequestSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
@@ -31,6 +41,8 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
...createVariableFetchSlice(...a),
|
||||
...createSettingsRequestSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { ExtendTimeWindow } from '../Panels/components/NoData/extendWindow';
|
||||
|
||||
/**
|
||||
* Kept out of `useDashboardStore` (never persisted): the View modal publishes its
|
||||
* local-window extender here so the deeply-nested `NoData` state reads it without
|
||||
* prop threading. Set while the modal is open, cleared on close.
|
||||
*/
|
||||
export interface ViewPanelStore {
|
||||
viewPanelExtendWindow: ExtendTimeWindow | null;
|
||||
setViewPanelExtendWindow: (extendWindow: ExtendTimeWindow | null) => void;
|
||||
}
|
||||
|
||||
export const useViewPanelStore = create<ViewPanelStore>((set) => ({
|
||||
viewPanelExtendWindow: null,
|
||||
setViewPanelExtendWindow: (viewPanelExtendWindow): void => {
|
||||
set({ viewPanelExtendWindow });
|
||||
},
|
||||
}));
|
||||
|
||||
export const selectViewPanelExtendWindow = (
|
||||
state: ViewPanelStore,
|
||||
): ExtendTimeWindow | null => state.viewPanelExtendWindow;
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
@@ -43,6 +44,9 @@ function PanelEditorPage(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
useDashboardFetch(dashboardId);
|
||||
|
||||
// Feed variables to the query builder autocomplete inside the editor.
|
||||
useSyncVariablesForSuggestions(dashboard);
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -102,6 +103,7 @@ function RenameDashboardModal({
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="Dashboard name"
|
||||
testId="rename-dashboard-input"
|
||||
onChange={(e): void => setName(e.target.value)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../DashboardPageV2/DashboardContainer/constants';
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
const DEFAULT_NAME = 'Sample Dashboard';
|
||||
@@ -79,6 +80,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Sample Dashboard"
|
||||
testId="create-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
export const VIEW_NAME_MAX_LENGTH = 128;
|
||||
export const VIEW_NAME_MAX_LENGTH = 64;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
||||
@@ -12,13 +12,24 @@
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&__mode-select {
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
// Dropdown content is rendered in a portal; bump above FloatingPanel
|
||||
// (z-index 999) so it stays visible when the consumer panel is floating.
|
||||
&__mode-dropdown {
|
||||
--dropdown-menu-content-z-index: 1000;
|
||||
}
|
||||
|
||||
// Shared content container — no scroll, each view handles its own
|
||||
&__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-top: 0px;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
@@ -7,16 +9,13 @@ import { PrettyView, PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
|
||||
import './DataViewer.styles.scss';
|
||||
|
||||
enum ViewMode {
|
||||
Pretty = 'pretty',
|
||||
Json = 'json',
|
||||
}
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
|
||||
|
||||
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
|
||||
{ label: 'Pretty', value: ViewMode.Pretty },
|
||||
{ label: 'JSON', value: ViewMode.Json },
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
];
|
||||
|
||||
export interface DataViewerProps {
|
||||
@@ -31,17 +30,12 @@ function DataViewer({
|
||||
drawerKey = 'default',
|
||||
prettyViewProps,
|
||||
}: DataViewerProps): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Pretty);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const handleViewModeChange = (value: string): void => {
|
||||
const next = value as ViewMode;
|
||||
// A single-select toggle can emit '' when the active item is toggled off;
|
||||
// ignore it so one mode is always selected.
|
||||
if (next !== ViewMode.Pretty && next !== ViewMode.Json) {
|
||||
return;
|
||||
}
|
||||
setViewMode(next);
|
||||
try {
|
||||
logEvent(VIEW_MODE_CHANGED_EVENT, {
|
||||
@@ -54,25 +48,49 @@ function DataViewer({
|
||||
}
|
||||
};
|
||||
|
||||
const currentLabel =
|
||||
VIEW_MODE_OPTIONS.find((opt) => opt.value === viewMode)?.label ?? 'Pretty';
|
||||
|
||||
return (
|
||||
<div className="data-viewer">
|
||||
<div className="data-viewer__toolbar">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
items={VIEW_MODE_OPTIONS}
|
||||
testId="data-viewer-view-mode"
|
||||
/>
|
||||
<Dropdown
|
||||
align="start"
|
||||
className="data-viewer__mode-dropdown"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: viewMode,
|
||||
onChange: handleViewModeChange,
|
||||
children: VIEW_MODE_OPTIONS.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.value,
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="data-viewer__mode-select"
|
||||
suffix={<ChevronDown size={12} />}
|
||||
>
|
||||
{currentLabel}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
|
||||
</div>
|
||||
|
||||
<div className="data-viewer__content">
|
||||
{viewMode === ViewMode.Pretty && (
|
||||
{viewMode === 'pretty' && (
|
||||
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
|
||||
)}
|
||||
{viewMode === ViewMode.Json && <JsonView data={jsonString} />}
|
||||
{viewMode === 'json' && <JsonView data={jsonString} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,9 +23,6 @@ const editorOptions: EditorProps['options'] = {
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
// Disabled: the transparent editor background leaves the sticky-scroll widget
|
||||
// without an opaque backing, so scrolling lines bleed through and overlap it.
|
||||
stickyScroll: { enabled: false },
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
|
||||
&__search-input {
|
||||
width: 100%;
|
||||
border: 0 !important;
|
||||
border-top: 1px solid var(--l2-border) !important;
|
||||
border-bottom: 1px solid var(--l2-border) !important;
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 18px !important;
|
||||
@@ -24,7 +22,6 @@
|
||||
box-shadow: none !important;
|
||||
|
||||
&:focus {
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-color: var(--primary) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
@@ -50,9 +47,6 @@
|
||||
> ul,
|
||||
&__pinned > ul {
|
||||
padding-left: 0 !important;
|
||||
// Clip the hover bleed to the panel width. `clip` (not `hidden`) does
|
||||
// not create a scroll container, so the sticky search stays working.
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
// Force font on all tree elements
|
||||
@@ -77,87 +71,45 @@
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
// Leaf node row — full-width hover highlight
|
||||
// Leaf node row — hover highlights only this row
|
||||
&__row {
|
||||
border-radius: 2px;
|
||||
display: flex !important;
|
||||
align-items: baseline;
|
||||
position: relative;
|
||||
isolation: isolate; // own stacking context so ::before sits behind content, not the panel bg
|
||||
|
||||
// Keep actions visible on hover, or while this row's menu is open
|
||||
&:hover .pretty-view__actions,
|
||||
&:has(> span [data-state='open']) .pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
// Edge-to-edge highlight, bled past indentation, clipped by `> ul`.
|
||||
// Persists while this row's ... menu is open (the trigger stays in the
|
||||
// row and carries data-state=open, even though the menu is portaled out).
|
||||
&:hover::before,
|
||||
&:has(> span [data-state='open'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 -9999px;
|
||||
background: var(--l3-background);
|
||||
z-index: -1;
|
||||
.pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Push actions to the right edge
|
||||
> span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Brighten the value text from its default grey to foreground-hover
|
||||
// while the row is active (hover, or its ... menu open). !important
|
||||
// overrides react-json-tree's per-type inline color on the value span.
|
||||
&:hover > span,
|
||||
&:has(> span [data-state='open']) > span {
|
||||
color: var(--l1-foreground-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested node (object/array) — full-width hover on the header line only
|
||||
// Nested node (object/array) — hover only on the label line, not children
|
||||
&__nested-row {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
> label,
|
||||
> span:not(ul span) {
|
||||
border-radius: 2px;
|
||||
padding: 1px 2px;
|
||||
display: inline !important; // keep item string inline with label
|
||||
}
|
||||
|
||||
// Edge-to-edge highlight, capped to the header line so it doesn't
|
||||
// cover the children this <li> contains. Persists while this row's own
|
||||
// menu is open — `> span` scopes it so a child row's open menu (nested
|
||||
// in `> ul`) doesn't light up this parent.
|
||||
&:hover::before,
|
||||
&:has(> span [data-state='open'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 20px; // 18px line-height + 2px top padding
|
||||
left: -9999px;
|
||||
right: -9999px;
|
||||
background: var(--l3-background);
|
||||
z-index: -1;
|
||||
// Highlight label + item string on hover, show actions
|
||||
&:hover > label,
|
||||
&:hover > label + span {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
&:hover > label + span .pretty-view__actions,
|
||||
&:has(> span [data-state='open']) > label + span .pretty-view__actions {
|
||||
&:hover > label + span .pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Push the ... to the right edge of the row instead of hugging the key.
|
||||
// Absolute (not flex) so the arrow/label/children layout stays intact;
|
||||
// the row <li> is position: relative (react-json-tree sets it inline).
|
||||
.pretty-view__actions {
|
||||
position: absolute;
|
||||
top: 2px; // align with the header line (li padding-top)
|
||||
right: 0;
|
||||
height: 18px; // line-height — centers the icon (span is align-items: center)
|
||||
}
|
||||
|
||||
// In nested rows, value-row should not take full width
|
||||
.pretty-view__value-row {
|
||||
width: auto;
|
||||
@@ -220,7 +172,6 @@
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
|
||||
&__pinned-icon {
|
||||
|
||||
Reference in New Issue
Block a user